From 2a47e6931ca3e8bd49d81a0ce4886f67ef563dac Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:07:26 +0100 Subject: [PATCH 01/19] Cover the bootstrap check, reader fallbacks and WooCommerce headers Nothing failed when the entry's bootstrap check, the int and string readers' fallbacks to a key's default, the greeting style's endpoint exclusion or DWS_TPL_DEBUG's threshold were removed, and nothing tied the entry's WC tested up to header to the WooCommerce that CI runs, so a Dependabot bump left the header claiming a version no lane tested. PartialDeployTest hides the bootstrap package's load.php, OptionsTest stores malformed values under keys with defaults, FeatureDetailTest loads the account page on an endpoint, PluginBootTest reads the logger's threshold under DWS_TPL_DEBUG, and a Unit row compares the header with the locked wp-plugin/woocommerce's major.minor. Each row fails on its mutant. Assisted-by: Claude Code:claude-opus-5-5 --- docs/recipes.md | 4 ++-- .../Integration/Feature/FeatureDetailTest.php | 15 ++++++++++++ tests/Integration/OptionsTest.php | 14 ++++++++++- tests/Integration/PartialDeployTest.php | 13 +++++++++++ tests/Integration/PluginBootTest.php | 15 ++++++++++++ tests/Unit/EntryHeadersTest.php | 23 +++++++++++++++++++ 6 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/EntryHeadersTest.php diff --git a/docs/recipes.md b/docs/recipes.md index b2abb4d..5473cda 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -915,7 +915,7 @@ composer remove --dev --ignore-platform-req=php+ --no-interaction ahegyes/wp-fra - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php ``` -3. In `dws-plugin-template.php`, delete the `Requires Plugins`, `WC requires at least` and `WC tested up to` headers. +3. In `dws-plugin-template.php`, delete the `Requires Plugins`, `WC requires at least` and `WC tested up to` headers, and in `tests/Unit/EntryHeadersTest.php` the row that ties `WC tested up to` to the locked WooCommerce. 4. In `functions.php`, delete the `FeaturesUtil` import, the `before_woocommerce_init` hook and `dws_tpl_declare_compatibility()`, and drop "declares its WooCommerce feature compatibility" from the file's summary: @@ -1258,7 +1258,7 @@ Uninstaller::run( - `OptionsTest` reads through `dws_tpl_get_bool_setting()` and `dws_tpl_get_int_setting()` from a `DWS_TPL_OPTION_SETTINGS` row; a malformed limit reads its default 1; the validator rows call `dws_tpl_validate_limit()`, which returns integers. - `MigrationsTest` expects `DWS_TPL_SETTING_DEFAULTS` stored autoloaded, a stored row kept as it is, and the throw when that option's `INSERT` fails. - `UninstallTest` consents through `array( DWS_TPL_SETTING_DELETE_DATA => 'yes' )`, seeds and asserts no Action Scheduler action, and expects the defaults stored again afterwards. -- `PluginBootTest` expects nothing in `$plugin->kernel->skipped`. +- `PluginBootTest` expects nothing in `$plugin->kernel->skipped`, and drops the `DWS_TPL_DEBUG` row with its `get_threshold()` helper and the `WooCommerceLogger` and `RunInSeparateProcess` imports. - `SettingsTest` builds `new OptionsPage( Plugin::PREFIX, new Settings() )` and calls its `register_settings()` in `setUpBeforeClass()`, which first requires `wp-admin/includes/template.php`. `tearDownAfterClass()` removes the page's `pre_update_option_` and `option_page_capability_` filters and calls `unregister_setting()`. Each test resets `$GLOBALS['wp_settings_errors']`, renders through `render_page()` and saves as `options.php` does: ```php diff --git a/tests/Integration/Feature/FeatureDetailTest.php b/tests/Integration/Feature/FeatureDetailTest.php index 696158e..00934fd 100644 --- a/tests/Integration/Feature/FeatureDetailTest.php +++ b/tests/Integration/Feature/FeatureDetailTest.php @@ -81,6 +81,21 @@ public function test_the_greeting_style_loads_on_the_account_dashboard_only(): v wp_deregister_style( 'dws-plugin-template-greeting' ); } + public function test_the_greeting_style_skips_the_account_page_endpoints(): void { + global $wp; + + add_filter( 'woocommerce_is_account_page', '__return_true' ); + $wp->query_vars['orders'] = ''; + try { + new FeatureDetail()->enqueue_style(); + } finally { + unset( $wp->query_vars['orders'] ); + remove_filter( 'woocommerce_is_account_page', '__return_true' ); + } + + self::assertFalse( wp_style_is( 'dws-plugin-template-greeting' ) ); + } + public function test_a_customer_whose_orders_reach_the_set_number_is_greeted_as_text(): void { update_option( DWS_TPL_OPTION_GREETING, 'Hi & welcome' ); update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' ); diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php index 920d224..49ae8fe 100644 --- a/tests/Integration/OptionsTest.php +++ b/tests/Integration/OptionsTest.php @@ -13,7 +13,7 @@ final class OptionsTest extends TestCase { protected const string KEY = Plugin::PREFIX . '_options_test'; - protected const array RESTORED = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING ); + protected const array RESTORED = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); protected array $stored = array(); @@ -107,6 +107,12 @@ public function test_an_int_reads_a_whole_number_or_zero( mixed $stored, int $ex self::assertSame( $expected, dws_tpl_get_int_option( self::KEY ) ); } + public function test_an_int_that_is_not_a_whole_number_reads_its_default(): void { + update_option( DWS_TPL_OPTION_GREETING_ORDERS, 'seven' ); + + self::assertSame( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ORDERS ], dws_tpl_get_int_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); + } + #[DataProvider( 'stored_strings' )] public function test_a_string_reads_a_stored_string_or_the_empty_string( mixed $stored, string $expected ): void { update_option( self::KEY, $stored ); @@ -136,6 +142,12 @@ public function test_a_string_without_a_stored_value_reads_its_translated_defaul self::assertSame( 'Hello', dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); } + public function test_a_string_that_is_not_a_string_reads_its_translated_default(): void { + update_option( DWS_TPL_OPTION_GREETING, array( 'Hello' ) ); + + self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); + } + public function test_an_option_without_a_translated_default_has_no_text(): void { self::assertSame( '', dws_tpl_get_option_text( DWS_TPL_OPTION_FEATURE ) ); } diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index 5e04219..3f4e5d3 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -25,6 +25,19 @@ public function test_a_missing_blocks_manifest_shows_the_build_notice_and_loads_ self::assertSame( '[false,true]', $request['last'] ); } + public function test_a_missing_bootstrap_package_shows_the_build_notice_and_loads_nothing_built(): void { + $bootstrap = \dirname( DWS_TPL_FILE ) . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php'; + \rename( $bootstrap, "$bootstrap.hidden" ); + try { + $request = self::request( 'echo wp_json_encode( array( function_exists( "dws_tpl_boot" ), false !== has_action( "all_admin_notices", "dws_tpl_render_build_notice" ) ) );' ); + } finally { + \rename( "$bootstrap.hidden", $bootstrap ); + } + + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( '[false,true]', $request['last'] ); + } + public function test_an_include_that_throws_shows_the_build_notice_and_boots_nothing(): void { $include = \dirname( DWS_TPL_FILE ) . '/includes/zz-throws.php'; \file_put_contents( $include, "getValue( new Plugin() ); + + self::assertInstanceOf( WooCommerceLogger::class, $logger ); + self::assertSame( self::get_threshold( new WooCommerceLogger( Plugin::SLUG, 'debug' ) ), self::get_threshold( $logger ) ); + } + public function test_the_stored_data_is_at_the_revision_of_the_code(): void { self::assertInstanceOf( MigrationRunner::class, dws_tpl_plugin()->migrations ); self::assertSame( \count( dws_tpl_get_migration_steps() ), MigrationRunner::get_revision( Plugin::PREFIX . '_revision' ) ); } + + protected static function get_threshold( WooCommerceLogger $logger ): mixed { + return new \ReflectionProperty( WooCommerceLogger::class, 'threshold' )->getValue( $logger ); + } } diff --git a/tests/Unit/EntryHeadersTest.php b/tests/Unit/EntryHeadersTest.php new file mode 100644 index 0000000..461ff05 --- /dev/null +++ b/tests/Unit/EntryHeadersTest.php @@ -0,0 +1,23 @@ + 'wp-plugin/woocommerce' === $package['name'] ); + + self::assertIsArray( $woocommerce ); + self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), self::get_header( 'WC tested up to' ) ); + } + + protected static function get_header( string $name ): string { + self::assertSame( 1, \preg_match( '/^\s*\*\s*' . \preg_quote( $name, '/' ) . ':\s*(\S+)\s*$/m', (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ), $header ) ); + return $header[1]; + } +} From 31bd6d13a2f7270b8abaf2e1450027565f8150b6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:07:59 +0100 Subject: [PATCH 02/19] Test a boot listener that throws dws_tpl_boot() catches every throwable from the boot, so a callback on dws_tpl/boot/succeeded that throws, an add-on's or another plugin's, is logged as a build that did not finish loading, and administrators are told to reinstall the plugin although it booted. The row hooks a throwing listener through an include and expects its own error, not the build error. It fails before the catch narrows. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/PluginBootTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Integration/PluginBootTest.php b/tests/Integration/PluginBootTest.php index bf21a3c..a272955 100644 --- a/tests/Integration/PluginBootTest.php +++ b/tests/Integration/PluginBootTest.php @@ -37,6 +37,19 @@ public function test_the_entry_loads_every_include_but_those_whose_names_start_w self::assertSame( 'skipped', $request['last'], $request['output'] ); } + public function test_a_boot_listener_that_throws_is_not_reported_as_an_incomplete_build(): void { + $include = \dirname( DWS_TPL_FILE ) . '/includes/zz-boot-listener.php'; + \file_put_contents( $include, " Date: Tue, 10 Nov 2026 16:08:42 +0100 Subject: [PATCH 03/19] Report only an unbuildable composition root as a build error dws_tpl_boot() and dws_tpl_deactivate() caught every throwable, so a hook listener's throw during the boot read as missing files: the log said the plugin did not finish loading and administrators were told to reinstall it, although it had booted. Both now catch only the construction of the composition root, the one failure their docblocks name. PluginBoot reports a failed composition itself, and anything after it surfaces as the code that threw. Assisted-by: Claude Code:claude-opus-5-5 --- functions.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/functions.php b/functions.php index 029a5dd..bbb9149 100644 --- a/functions.php +++ b/functions.php @@ -57,10 +57,13 @@ function dws_tpl_plugin(): Plugin { */ function dws_tpl_boot(): void { try { - dws_tpl_plugin()->boot(); + $plugin = dws_tpl_plugin(); } catch ( Throwable $error ) { dws_tpl_handle_build_error( $error ); + return; } + + $plugin->boot(); // PluginBoot reports a failed composition itself, and a hook listener's throw is not a build error. } /** @@ -73,10 +76,13 @@ function dws_tpl_boot(): void { */ function dws_tpl_deactivate(): void { try { - dws_tpl_plugin()->deactivate(); + $plugin = dws_tpl_plugin(); } catch ( Throwable $error ) { dws_tpl_handle_build_error( $error ); + return; } + + $plugin->deactivate(); } /** From 78c7f754a47491675d7a37dae6841f8eaa5b52f3 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:08:55 +0100 Subject: [PATCH 04/19] Test a build without its assets tree The entry's build gate checks only the blocks manifest, which the build writes before the assets, so a failed or skipped assets build passes the gate, and the block editor and the account page, which require the assets' *.asset.php files unguarded, fail on every load. The row hides assets/build/editor.asset.php and expects the build notice with nothing built loaded, as the manifest's row does. It fails before the gate checks the assets tree. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/PartialDeployTest.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index 3f4e5d3..aa9ce54 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -25,6 +25,19 @@ public function test_a_missing_blocks_manifest_shows_the_build_notice_and_loads_ self::assertSame( '[false,true]', $request['last'] ); } + public function test_a_missing_assets_build_shows_the_build_notice_and_loads_nothing_built(): void { + $asset = \dirname( DWS_TPL_FILE ) . '/assets/build/editor.asset.php'; + \rename( $asset, "$asset.hidden" ); + try { + $request = self::request( 'echo wp_json_encode( array( function_exists( "dws_tpl_boot" ), false !== has_action( "all_admin_notices", "dws_tpl_render_build_notice" ) ) );' ); + } finally { + \rename( "$asset.hidden", $asset ); + } + + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( '[false,true]', $request['last'] ); + } + public function test_a_missing_bootstrap_package_shows_the_build_notice_and_loads_nothing_built(): void { $bootstrap = \dirname( DWS_TPL_FILE ) . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php'; \rename( $bootstrap, "$bootstrap.hidden" ); From 893f3ee9e99494a86ee2599cf064302c97f02384 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:10:00 +0100 Subject: [PATCH 05/19] Gate the build on both trees it reads, in consumer-a's form The build gate checked only the blocks manifest, which npm run build writes before the assets, so a build that stopped after the blocks passed it, and the block editor and the account page then failed on the assets' *.asset.php files, which the plugin requires unchecked. The gate also read the bootstrap check twice: once to load the floor gate and again, negated beside the manifest, under one comment for both. The entry now returns early when the bootstrap package is missing, as consumer-a does, then loads it, registers the updater and checks the floors, and only then checks the build, the blocks manifest and assets/build/editor.asset.php together, each guard with its own comment. ARCHITECTURE.md and the scaffold README name both files. Assisted-by: Claude Code:claude-opus-5-5 --- README.scaffold.md | 2 +- docs/ARCHITECTURE.md | 4 ++-- dws-plugin-template.php | 17 ++++++++++------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/README.scaffold.md b/README.scaffold.md index e8bd443..ffec189 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -61,7 +61,7 @@ The fill-in renamed the plugin and kept the template's demo, which boots and pas Whichever parts go, four settings follow what is left, since a plugin removes them in any order: -- The entry's build gate checks `blocks/build/blocks-manifest.php`, and the plugin stays off until that file exists, so point it at a file the remaining build writes, such as `assets/build/editor.asset.php`, or drop its built-file check once nothing is built. +- The entry's build gate checks `blocks/build/blocks-manifest.php` and `assets/build/editor.asset.php`, and the plugin stays off until both exist, so it checks only files the remaining build writes, and drops its built-file check once nothing is built. - `lint:scripts` and `format:scripts` name only folders that still hold a script, since ESLint fails on a path that holds none. - `lint:styles` names only folders that still hold SCSS. Once none does, it keeps one pattern, such as `'assets/src/**/*.scss'`, and takes `--allow-empty-input`, since Stylelint fails when nothing matches, Quality runs it, and without a pattern wp-scripts lints every stylesheet in the project. - `build` stays, even once it runs nothing, since `npm run setup` and the Integration run call it. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 843f421..8115250 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -8,8 +8,8 @@ This document describes how the plugin loads, boots, stores its data and leaves 1. It defines `DWS_TPL_FILE` and registers `languages/`, which WordPress skips for a network-activated plugin. 2. It loads `includes/safeguards.php`, whose restrictions hold on any runtime that parses the plugin: below the floors, on a partial deploy and when the boot fails. -3. It requires `vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php`, registers the GitHub updater, then checks the `Requires PHP` and `Requires at least` headers. The updater comes first, so a release that lowers the floors still reaches a site below them. On a site below a floor, WordPress's own notice shows and nothing past the safeguards loads. -4. It stops with a notice when the prefixed framework or the built blocks are missing. It then requires the autoloader and `functions.php`; a missing file throws, which local and development environments rethrow and every other environment logs, showing the same notice. +3. It stops with a notice when the prefixed framework is missing. Otherwise it requires `vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php`, registers the GitHub updater, then checks the `Requires PHP` and `Requires at least` headers. The updater comes first, so a release that lowers the floors still reaches a site below them. On a site below a floor, WordPress's own notice shows and nothing past the safeguards loads. +4. It stops with the same notice when the built blocks or assets are missing. It then requires the autoloader and `functions.php`; a missing file throws, which local and development environments rethrow and every other environment logs, showing the same notice. `functions.php` defines the `dws_tpl_plugin()` accessor, boots the plugin on `plugins_loaded` at priority 15, after WooCommerce and Action Scheduler have loaded, registers the deactivation callback, declares the plugin's WooCommerce feature compatibility, and requires every `includes/*.php` in name order except those whose names start with `_`. diff --git a/dws-plugin-template.php b/dws-plugin-template.php index 8368fff..7e1611d 100644 --- a/dws-plugin-template.php +++ b/dws-plugin-template.php @@ -32,15 +32,18 @@ require_once __DIR__ . '/includes/safeguards.php'; // Before the floor gate, so the restrictions hold on any runtime that parses the plugin. $dws_tpl_bootstrap = __DIR__ . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php'; -if ( is_readable( $dws_tpl_bootstrap ) ) { - require_once $dws_tpl_bootstrap; - Updater::register( __FILE__ ); // Before the floor gate, so a release that lowers the floors still reaches a site below them. - if ( ! Requirements::check_floors( __FILE__ ) ) { - return; - } +if ( ! is_readable( $dws_tpl_bootstrap ) ) { // The floor gate lives in the bootstrap package, so nothing built may load without it. + add_action( 'all_admin_notices', 'dws_tpl_render_build_notice' ); + return; +} + +require_once $dws_tpl_bootstrap; +Updater::register( __FILE__ ); // Before the floor gate, so a release that lowers the floors still reaches a site below them. +if ( ! Requirements::check_floors( __FILE__ ) ) { + return; } -if ( ! is_readable( $dws_tpl_bootstrap ) || ! is_readable( __DIR__ . '/blocks/build/blocks-manifest.php' ) ) { // The floor gate never ran, or the scripts were never built, so nothing built may load. +if ( ! is_readable( __DIR__ . '/blocks/build/blocks-manifest.php' ) || ! is_readable( __DIR__ . '/assets/build/editor.asset.php' ) ) { // The plugin reads both build trees unchecked, so a build that wrote only one of them must not load. add_action( 'all_admin_notices', 'dws_tpl_render_build_notice' ); return; } From b6c3782570734738746cfe8d66fbf1c392125475 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:10:28 +0100 Subject: [PATCH 06/19] Test the build-error log's location and frequency The entry logs a build error with its message only and on every request, so a partial deploy writes one line per front-end, admin, REST and cron request until someone redeploys, and an error other than a missing file, such as a throwing include, does not say where it came from. PluginBoot logs a repeated failure once an hour. The row makes an include throw a message unique to the run, requests the site twice, and expects the first log line to name the include's path and line and the second request to log nothing. It fails before the log changes. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/PartialDeployTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index aa9ce54..7659cfd 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; @@ -64,6 +65,22 @@ public function test_an_include_that_throws_shows_the_build_notice_and_boots_not self::assertSame( '[false,true]', $request['last'] ); } + public function test_a_build_error_is_logged_with_its_location_once_an_hour(): void { + $message = 'A partial deploy ' . \uniqid() . '.'; + $include = \dirname( DWS_TPL_FILE ) . '/includes/zz-throws.php'; + \file_put_contents( $include, " Date: Tue, 10 Nov 2026 16:11:00 +0100 Subject: [PATCH 07/19] Log build errors with their location, about once an hour A build error was logged with its message only, on every request, so a partial deploy wrote one line per request until someone redeployed, and an error other than a missing file did not say where it came from. dws_tpl_handle_build_error() appends the error's file and line, and logs each distinct message at most once an hour through a transient keyed on the message, as PluginBoot throttles its boot failures. The transient lives under the plugin's prefix, so the footprint covers it, and a transient that fails to save costs one more log line. Assisted-by: Claude Code:claude-opus-5-5 --- dws-plugin-template.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/dws-plugin-template.php b/dws-plugin-template.php index 7e1611d..2b92897 100644 --- a/dws-plugin-template.php +++ b/dws-plugin-template.php @@ -57,7 +57,7 @@ } /** - * Reports a build that could not load, rethrowing in local and development environments and otherwise logging the error and showing the build notice. + * Reports a build that could not load, rethrowing in local and development environments and otherwise logging the error with its location and showing the build notice. * * @since 2.0.0 * @version 2.0.0 @@ -73,7 +73,13 @@ function dws_tpl_handle_build_error( Throwable $error ): void { throw $error; } - error_log( 'DWS Plugin Template did not finish loading: ' . $error->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + // A partial deploy fails the same way on every request, so each distinct message is logged about once an hour, as best effort. + $key = 'dws_tpl_build_error_' . md5( $error->getMessage() ); + if ( false === get_transient( $key ) ) { + set_transient( $key, 1, HOUR_IN_SECONDS ); + error_log( 'DWS Plugin Template did not finish loading: ' . $error->getMessage() . ' in ' . $error->getFile() . ':' . $error->getLine() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + } + add_action( 'all_admin_notices', 'dws_tpl_render_build_notice' ); } From b83a6bcd0f8430c6a7443fbc5c2a5e9a080c4afa Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:12:09 +0100 Subject: [PATCH 08/19] Analyse the entry at its oldest runtime, beside the safeguards The entry runs before the floor gate on any runtime that parses the plugin, yet PHPStan checked it only at the plugin's floors and Quality only parsed it on PHP 7.4, so a PHP 8 function or a WordPress API newer than the bootstrap package's floor added before the gate would pass every check and fatal on the sites the gate exists for. phpstan.safeguards.neon analyses the entry with the safeguards at PHP 7.4 and WordPress 5.2, scans the scoped bootstrap package the entry loads by path, and ignores wp_get_environment_type() in the entry by path, since only the build-error handler calls it, once the floor gate has passed. The main profile scans the entry instead of analysing it, as it does the safeguards, since the two profiles would disagree on a pre-gate guard. ARCHITECTURE.md names the check, and the Without WooCommerce recipe deletes only the stubs from scanFiles. Assisted-by: Claude Code:claude-opus-5-5 --- docs/ARCHITECTURE.md | 2 +- docs/recipes.md | 7 +++---- phpstan.dist.neon | 4 ++-- phpstan.safeguards.neon | 12 +++++++++++- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8115250..3649c56 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ This document describes how the plugin loads, boots, stores its data and leaves ## Loading -`dws-plugin-template.php` is the only file WordPress includes, and it parses on PHP 7.4 so that a site below the floors gets a notice instead of a fatal error: +`dws-plugin-template.php` is the only file WordPress includes, and it parses on PHP 7.4 so that a site below the floors gets a notice instead of a fatal error; `phpstan.safeguards.neon` analyzes it with the safeguards at PHP 7.4 and the oldest WordPress the bootstrap package runs on: 1. It defines `DWS_TPL_FILE` and registers `languages/`, which WordPress skips for a network-activated plugin. 2. It loads `includes/safeguards.php`, whose restrictions hold on any runtime that parses the plugin: below the floors, on a partial deploy and when the boot fails. diff --git a/docs/recipes.md b/docs/recipes.md index 5473cda..79634b6 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -907,12 +907,11 @@ A plugin that does not need WooCommerce drops the WooCommerce package, the WooCo composer remove --dev --ignore-platform-req=php+ --no-interaction ahegyes/wp-framework-woocommerce php-stubs/woocommerce-stubs wp-plugin/woocommerce ``` -2. In `phpstan.dist.neon`, delete the `scanFiles` block that names the WooCommerce stubs: +2. In `phpstan.dist.neon`, delete the WooCommerce stubs from `scanFiles`: ```neon - scanFiles: - - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php - - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php + - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php + - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php ``` 3. In `dws-plugin-template.php`, delete the `Requires Plugins`, `WC requires at least` and `WC tested up to` headers, and in `tests/Unit/EntryHeadersTest.php` the row that ties `WC tested up to` to the locked WooCommerce. diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 41e5b67..1b2f489 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -7,16 +7,16 @@ includes: parameters: paths: - - dws-plugin-template.php - functions.php - uninstall.php - includes - models - src - # phpstan.safeguards.neon analyzes the safeguards at the oldest runtime they parse on. + # phpstan.safeguards.neon analyzes the entry and the safeguards at the oldest runtime they parse on, so this profile only scans them. excludePaths: - includes/safeguards.php scanFiles: + - dws-plugin-template.php - includes/safeguards.php - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php diff --git a/phpstan.safeguards.neon b/phpstan.safeguards.neon index e2f6abc..8e38407 100644 --- a/phpstan.safeguards.neon +++ b/phpstan.safeguards.neon @@ -1,4 +1,4 @@ -# The safeguards load before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. +# The entry and the safeguards run before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -7,7 +7,17 @@ includes: parameters: paths: + - dws-plugin-template.php - includes/safeguards.php phpVersion: 70400 WPCompat: requiresAtLeast: '5.2' + # The entry loads the scoped bootstrap package by path, before any autoloader. + scanDirectories: + - vendor-prefixed/ahegyes/wp-framework-bootstrap + ignoreErrors: + # The entry's build-error handler runs only once the floor gate has passed. + - + identifier: WPCompat.functionNotAvailable + message: '#^wp_get_environment_type\(\) #' + path: dws-plugin-template.php From f41ab2ae995059d98fe9fa65f98d9d83f6b3faa6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:12:26 +0100 Subject: [PATCH 09/19] Test that PHPStan checks WordPress calls at the entry's floor The plugin's PHPStan inherits wp-compat's WordPress floor from configs' profile, not from the entry's Requires at least, so a plugin that raises or lowers its floor keeps being checked at configs' version, and a call to an API its floor lacks can pass. The Unit row reads WPCompat.requiresAtLeast from phpstan.dist.neon and expects the entry's Requires at least. It fails until the profile sets it. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Unit/EntryHeadersTest.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Unit/EntryHeadersTest.php b/tests/Unit/EntryHeadersTest.php index 461ff05..4d7cd5b 100644 --- a/tests/Unit/EntryHeadersTest.php +++ b/tests/Unit/EntryHeadersTest.php @@ -16,6 +16,11 @@ public function test_wc_tested_up_to_names_the_locked_woocommerce(): void { self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), self::get_header( 'WC tested up to' ) ); } + public function test_phpstan_checks_wordpress_calls_at_the_entry_floor(): void { + self::assertSame( 1, \preg_match( "/^\tWPCompat:\n\t\trequiresAtLeast: '([^']+)'$/m", (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/phpstan.dist.neon' ), $floor ) ); + self::assertSame( self::get_header( 'Requires at least' ), $floor[1] ); + } + protected static function get_header( string $name ): string { self::assertSame( 1, \preg_match( '/^\s*\*\s*' . \preg_quote( $name, '/' ) . ':\s*(\S+)\s*$/m', (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ), $header ) ); return $header[1]; From 87265e9a084e457e79e30e4869fdae5ff8089503 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:13:08 +0100 Subject: [PATCH 10/19] Check WordPress calls at the entry's own floor PHPStan's wp-compat took its floor from configs' profile, so a plugin whose Requires at least differs from configs' version was checked at the wrong one, and nothing stated the rule the check serves: a plugin uses only APIs its WordPress floor has, while @wordpress/* stays on npm's latest because WordPress supplies those packages at runtime. phpstan.dist.neon sets WPCompat.requiresAtLeast to the entry's Requires at least, which the Unit row pins, and its header comment drops the include-order reason the setting makes moot. AGENTS.md states the floor rule with its two checks, wp-compat and the end-to-end suite on the floor's WordPress, and the README says why packages-update:all moves @wordpress/* to npm's latest. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 1 + README.md | 6 ++++++ phpstan.dist.neon | 5 ++++- tests/README.md | 2 +- 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 94e47f4..9a50440 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru 6. **Options and data.** Options are read only through `includes/options.php`'s typed readers, safeguards excepted. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else its own entry. 7. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, or guard them, which `phpstan.safeguards.neon` checks. 8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. +9. **WordPress floor.** The plugin calls only APIs its `Requires at least` has, PHP's and JavaScript's alike: `phpstan.dist.neon`'s `WPCompat.requiresAtLeast` follows the header, which a Unit row pins, and CI runs the end-to-end suite on the floor's WordPress, which supplies the `@wordpress/*` scripts at runtime. ## Style diff --git a/README.md b/README.md index bfeb07d..dda2f58 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,12 @@ Lint the scripts, styles and types: npm run lint ``` +Update the dependencies within their constraints, and `@wordpress/*` to npm's latest rather than the WordPress floor's versions, since WordPress supplies those packages at runtime and PHPStan and the end-to-end suite on the floor's WordPress keep the plugin to its floor's APIs: + +```sh +npm run packages-update:all +``` + Every pull request also rehearses the fill-in with sample inputs and gates the filled-in plugin, and rehearses the release on the built zip. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the changelog, and `SECURITY.md` how to report a vulnerability. ## License diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 1b2f489..d9b301d 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,4 +1,4 @@ -# The WordPress extensions are listed here because composer.json keeps them from installing themselves, and before configs' profile because wp-compat's neon resets WPCompat.requiresAtLeast to null. +# The WordPress extensions are listed here because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -23,3 +23,6 @@ parameters: # The scoped packages load through vendor-prefixed/scoper-autoload.php, which PHPStan does not read. scanDirectories: - vendor-prefixed + # The entry's Requires at least, so no call reaches an API newer than the plugin's WordPress floor. + WPCompat: + requiresAtLeast: '7.1' diff --git a/tests/README.md b/tests/README.md index 67c13d4..91147bb 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,7 +6,7 @@ This document describes the plugin's three test suites, the environments they ru | Suite | Where | Runs on | Covers | | --- | --- | --- | --- | -| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree and the footprint's shape and coverage | +| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage, and the entry's headers against the locked WooCommerce and PHPStan's WordPress floor | | Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded, and the multisite site for its `multisite` group | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, the peer getters, a partial deploy: a missing build file or engine, or an include that throws, and the boot and settings on a second site of a network | | End to end | `tests/EndToEnd/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, and the plugin with WooCommerce deactivated | From 7dcaf1a0f89dd9eac2414bf26cb65a3c099f40f6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:13:34 +0100 Subject: [PATCH 11/19] Publish no release from the template itself The release workflow published on any v* tag, with nothing telling the template from a plugin built from it, so a tag on the template would publish its zip as a GitHub release, which any site that installed the template follows through its Update URI, and nothing said whether the template versions itself. The release job publishes only on a tag in a repository that is not a template, so a tag on the template rehearses the release, as a pull request does. The README says the template publishes no release and takes no changelog fragments, which the fill-in deletes. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/release.yml | 4 ++-- README.md | 2 +- tests/README.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa1138b..121e1fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -51,5 +51,5 @@ jobs: wp-env-config-file: '.wp-env.tests.json' # Both parse every PHP file with php-parser 4, which stops at PHP 8.4 syntax such as protected(set). exclude-checks: 'prefixing,direct_file_access' - # A pull request or a dispatch on a branch rehearses the release without publishing it. - publish: ${{ github.ref_type == 'tag' }} + # A pull request, a dispatch on a branch or any tag on the template rehearses the release without publishing it. + publish: ${{ github.ref_type == 'tag' && !github.event.repository.is_template }} diff --git a/README.md b/README.md index dda2f58..dff9225 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Update the dependencies within their constraints, and `@wordpress/*` to npm's la npm run packages-update:all ``` -Every pull request also rehearses the fill-in with sample inputs and gates the filled-in plugin, and rehearses the release on the built zip. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the changelog, and `SECURITY.md` how to report a vulnerability. +Every pull request also rehearses the fill-in with sample inputs and gates the filled-in plugin, and rehearses the release on the built zip. The template itself publishes no release, even on a tag, and takes no changelog fragments, which the fill-in deletes. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the changelog, and `SECURITY.md` how to report a vulnerability. ## License diff --git a/tests/README.md b/tests/README.md index 91147bb..131458c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -57,7 +57,7 @@ composer quality-check - **Tests** runs Unit once, and Integration, its `multisite` group and the end-to-end suite once per WordPress branch, from the entry file's `Requires at least` up to the newest release, which WordPress's version API lists, so a new release joins without an edit. - **Tests (nightly)** runs Integration, its `multisite` group and the end-to-end suite on WordPress trunk every day, outside pull requests. -- **Release** builds the zip on every pull request as a tag would, runs Plugin Check and the archive checks, then activates the zip on a site and runs the end-to-end suite against it. +- **Release** builds the zip on every pull request as a tag would, runs Plugin Check and the archive checks, then activates the zip on a site and runs the end-to-end suite against it; it publishes only on a tag, and never on the template. - **Quality** runs PHPCS, PHPStan, the PHP 7.4 parse of the entry file, `uninstall.php` and `includes/`, the script, style and type lints, the `block.json` schema and the changelog fragments. ## Conventions From 5e6e253f3af9d2d54a4714c8f924f7e7d9b60a67 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:14:18 +0100 Subject: [PATCH 12/19] Seed every plugin at 1.0.0 The template carried 2.0.0, the framework's version, in its Version header, its @since and @version tags and its changelog heading, and the fill-in rewrote all three to 0.1.0, so a new plugin started below its first release while the template claimed a version of its own. The template reads 1.0.0 in all three places, the version a new plugin first releases, and the fill-in drops its three version rewrites and keeps deleting changelog fragments; the README's fill-in list says so. A v1 successor sets 2.0.0 itself. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 4 - CHANGELOG.md | 2 +- README.md | 3 +- docs/recipes.md | 200 ++++++++++++++-------------- dws-plugin-template.php | 10 +- functions.php | 20 +-- includes/_disabled-example.php | 4 +- includes/assets.php | 16 +-- includes/footprint.php | 8 +- includes/metadata.php | 16 +-- includes/migrations.php | 12 +- includes/options.php | 52 ++++---- includes/safeguards.php | 4 +- src/Blocks.php | 16 +-- src/Feature/Feature.php | 16 +-- src/Feature/FeatureDetail.php | 16 +-- src/Plugin.php | 56 ++++---- src/Settings.php | 16 +-- uninstall.php | 4 +- 19 files changed, 235 insertions(+), 240 deletions(-) diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index 165ab26..012fa95 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -52,11 +52,7 @@ git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold. git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json' | xargs -0 perl -pi -e ' BEGIN { @ARGV = grep { ! -l $_ } @ARGV } s/\b(881[123])\b/8811 == $1 ? $ENV{PORT} : 8812 == $1 ? $ENV{TESTS_PORT} : $ENV{MULTISITE_PORT}/ge; - s/^(\s*\*\s*\@(?:since|version)\s+)2\.0\.0$/${1}0.1.0/; ' -# shellcheck disable=SC2016 -perl -pi -e 's/^(\s*\*\s*Version:\s*)2\.0\.0$/${1}0.1.0/' "$SLUG.php" -perl -pi -e 's/^## 2\.0\.0 - unreleased$/## 0.1.0 - unreleased/' CHANGELOG.md find changelog -type f ! -name '.gitkeep' -delete # The sweep renamed the package, so the lock's content hash follows, or composer validate --strict fails. diff --git a/CHANGELOG.md b/CHANGELOG.md index cc205c3..d4faa6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this plugin are documented in this file. Format follows [ Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add`. Aggregate into a release with `composer changelog:write`. -## 2.0.0 - unreleased +## 1.0.0 - unreleased ### Added - Initial release. diff --git a/README.md b/README.md index dff9225..b5685c7 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,8 @@ Create the plugin's repository from this template with GitHub's **Use this templ The workflow: - renames the entry file and replaces the template's names, prefix and ports in every tracked file outside `.github/workflows/`, which read the slug from `composer.json` instead, since GitHub refuses the workflow's own token for workflow changes; -- resets the version to 0.1.0 and the changelog to one unreleased entry; - makes `README.scaffold.md` the plugin's README, which lists the template's demo and what removing each part takes; -- deletes the disabled example include and refreshes the Composer lock's content hash; +- deletes the disabled example include and any changelog fragments, and refreshes the Composer lock's content hash; - fails when any name or port of the template is left, and otherwise pushes one commit to the branch it ran on. ## Naming diff --git a/docs/recipes.md b/docs/recipes.md index 79634b6..9cf42e8 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -26,8 +26,8 @@ Replace `includes/safeguards.php` with the helper and the safeguard. The file ru /** * Holds the plugin's safeguards, which load before its floor gate, so they hold on a runtime below its floors and when the rest of the build fails to load. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -39,8 +39,8 @@ add_filter( 'map_meta_cap', 'dws_tpl_map_export_capability', 10, 2 ); /** * Returns whether a safeguard's filtered value relaxes it, which takes an explicit false once the plugin has finished booting. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param mixed $filtered The value the safeguard's filter returned for true. * @@ -53,8 +53,8 @@ function dws_tpl_is_relaxed( $filtered ): bool { /** * Maps the data export capability to do_not_allow, or to managing options while the plugin relaxes the export lock. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string[] $caps The primitive capabilities the capability maps to. * @param string $cap The capability being checked. @@ -69,8 +69,8 @@ function dws_tpl_map_export_capability( array $caps, string $cap ): array { /** * Filters whether the data export stays locked; an explicit false unlocks it once the plugin has booted. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param bool $restricted Whether the data export stays locked. */ @@ -161,8 +161,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Admin /** * Constructor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param AdminNotices $notices The plugin's admin notices. */ @@ -175,8 +175,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Admin /** * Tells the current user, on their next admin screen, how many rows the export wrote. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param int $rows The number of rows the export wrote. * @@ -206,8 +206,8 @@ Add the option, its field and its default, a reader that guards the field and th /** * The option that holds the settings page's fields, keyed by field id. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -216,8 +216,8 @@ const DWS_TPL_OPTION_SETTINGS = Plugin::PREFIX . '_settings'; /** * The settings field that holds the limit. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -226,8 +226,8 @@ const DWS_TPL_SETTING_LIMIT = 'limit'; /** * The value each settings field reads as while nothing valid is stored. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var array */ @@ -238,8 +238,8 @@ const DWS_TPL_SETTING_DEFAULTS = array( /** * Returns a settings field as an integer. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @@ -256,8 +256,8 @@ function dws_tpl_get_int_setting( string $field ): int { /** * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param mixed $submitted The submitted value. * @@ -286,8 +286,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Settings\A /** * Adds the plugin's settings page under Settings. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class SettingsPage implements ComponentInterface, OptionsPageDefinitionInterface { // region INHERITED METHODS @@ -295,8 +295,8 @@ final class SettingsPage implements ComponentInterface, OptionsPageDefinitionInt /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -306,8 +306,8 @@ final class SettingsPage implements ComponentInterface, OptionsPageDefinitionInt /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_options_page( string $page_id ): array { @@ -363,8 +363,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerc /** * Adds the packing tab to the product editor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class ProductData implements ComponentInterface, ProductDataTabDefinitionInterface { // region INHERITED METHODS @@ -372,8 +372,8 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -383,8 +383,8 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_product_data_tab( string $tab_id, \WC_Product $product ): array { @@ -436,8 +436,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Settings\E /** * Adds an editorial note to the post editor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterface { // region MAGIC METHODS @@ -445,8 +445,8 @@ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterfac /** * Constructor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param AdminNotices $notices The plugin's admin notices, which report the form's rejections. */ @@ -461,8 +461,8 @@ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterfac /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -472,8 +472,8 @@ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterfac /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_object_form( string $form_id, int $object_id ): array { @@ -515,8 +515,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerc /** * Adds the plugin's section to WooCommerce's Payments tab, and links to it from the plugin's row. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class PaymentSettings implements ComponentInterface, SectionFieldsDefinitionInterface { // region INHERITED METHODS @@ -524,8 +524,8 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -536,8 +536,8 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_section_fields( string $tab_id, string $section_id ): array { @@ -567,8 +567,8 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti /** * Registers the plugin's section on the Payments tab. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param SettingsSectionRegistry $registry WooCommerce's registry of settings sections. * @@ -590,8 +590,8 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti /** * Adds the link to the plugin's section to its row on the Plugins screen. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string[] $links The row's action links. * @@ -619,8 +619,8 @@ Add the repair gate's option to `includes/options.php`: /** * The option that holds when the plugin last ensured its recurring jobs, which lets a request skip ensuring them for an hour. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -645,8 +645,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\Psr\Log\LoggerInterface as PsrLoggerI /** * Prunes the plugin's expired records once a day. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Pruning implements ComponentInterface { // region FIELDS AND CONSTANTS @@ -654,8 +654,8 @@ final class Pruning implements ComponentInterface { /** * The hook the daily prune runs on. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -668,8 +668,8 @@ final class Pruning implements ComponentInterface { /** * Constructor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param SchedulerInterface $scheduler The plugin's scheduler. * @param PsrLoggerInterface $logger The plugin's logger. @@ -686,8 +686,8 @@ final class Pruning implements ComponentInterface { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -703,8 +703,8 @@ final class Pruning implements ComponentInterface { /** * Ensures the daily prune unless a request did so within the last hour. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -721,8 +721,8 @@ final class Pruning implements ComponentInterface { /** * Schedules the daily prune unless it runs at that interval already, logging a scheduler that refuses. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -743,8 +743,8 @@ final class Pruning implements ComponentInterface { /** * Deletes the records that have expired. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -775,8 +775,8 @@ register_activation_hook( DWS_TPL_FILE, 'dws_tpl_activate' ); /** * Lets the next request rebuild the site's rewrite rules with the plugin's post types and taxonomies. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -961,8 +961,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra /** * Registers the scheduler and the migrations, then builds the kernel and boots the plugin's components. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return bool */ @@ -1001,8 +1001,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Settings\A /** * Adds the plugin's settings page under Settings, which holds the limit and the consent to delete the plugin's data on uninstall. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Settings implements ComponentInterface, OptionsPageDefinitionInterface { // region INHERITED METHODS @@ -1010,8 +1010,8 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -1021,8 +1021,8 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_options_page( string $page_id ): array { @@ -1071,8 +1071,8 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa /** * Holds the plugin's settings option, its fields and their defaults, and reads each field as its type, guarding the stored value. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -1084,8 +1084,8 @@ defined( 'ABSPATH' ) || exit; /** * The option that holds the settings page's fields, keyed by field id. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -1094,8 +1094,8 @@ const DWS_TPL_OPTION_SETTINGS = Plugin::PREFIX . '_settings'; /** * The settings field that holds 'yes' where the site consents to uninstall removing the plugin's data. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -1104,8 +1104,8 @@ const DWS_TPL_SETTING_DELETE_DATA = 'delete_data'; /** * The settings field that holds the limit. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -1114,8 +1114,8 @@ const DWS_TPL_SETTING_LIMIT = 'limit'; /** * The value each settings field reads as while nothing valid is stored. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var array */ @@ -1127,8 +1127,8 @@ const DWS_TPL_SETTING_DEFAULTS = array( /** * Returns a settings field as a boolean, which reads checkbox values such as 'yes' and 'no'. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @@ -1143,8 +1143,8 @@ function dws_tpl_get_bool_setting( string $field ): bool { /** * Returns a settings field as an integer. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @@ -1159,8 +1159,8 @@ function dws_tpl_get_int_setting( string $field ): int { /** * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param mixed $submitted The submitted value. * @@ -1175,8 +1175,8 @@ function dws_tpl_validate_limit( $submitted ) { /** * Returns a settings field's stored value, or the fallback while the settings hold none. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @param mixed $fallback The value to return while nothing is stored. @@ -1192,8 +1192,8 @@ function dws_tpl_get_setting( string $field, $fallback ) { /** * Returns a settings field's default, or null for a field without one. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @@ -1210,8 +1210,8 @@ function dws_tpl_get_setting_default( string $field ) { /** * Stores the settings' defaults, autoloaded, so a site that never saved the settings page reads them without a query. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @throws RuntimeException Thrown when the defaults could not be stored. * diff --git a/dws-plugin-template.php b/dws-plugin-template.php index 2b92897..567d84e 100644 --- a/dws-plugin-template.php +++ b/dws-plugin-template.php @@ -3,7 +3,7 @@ * Plugin Name: DWS Plugin Template * Plugin URI: https://github.com/ahegyes/wordpress-plugin-template * Description: WordPress plugins on the wp-framework packages start here, from one tested anatomy, so all are built alike and a fix found in one applies to all. - * Version: 2.0.0 + * Version: 1.0.0 * Requires at least: 7.1 * Tested up to: 7.1 * Requires PHP: 8.5 @@ -59,8 +59,8 @@ /** * Reports a build that could not load, rethrowing in local and development environments and otherwise logging the error with its location and showing the build notice. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param Throwable $error The error that stopped the build. * @@ -86,8 +86,8 @@ function dws_tpl_handle_build_error( Throwable $error ): void { /** * Prints the notice that the plugin's build is incomplete to users who can activate plugins. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ diff --git a/functions.php b/functions.php index bbb9149..94b0081 100644 --- a/functions.php +++ b/functions.php @@ -2,8 +2,8 @@ /** * Defines the plugin's accessor, boots it on plugins_loaded, unschedules its jobs on deactivation, declares its WooCommerce feature compatibility and loads the files in includes/. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -33,8 +33,8 @@ /** * Returns the plugin's composition root, built on first use. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return Plugin */ @@ -50,8 +50,8 @@ function dws_tpl_plugin(): Plugin { /** * Boots the plugin, reporting a composition root that cannot be built as an incomplete build. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -69,8 +69,8 @@ function dws_tpl_boot(): void { /** * Unschedules the plugin's jobs on the site it is deactivated on, reporting a composition root that cannot be built as an incomplete build. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -88,8 +88,8 @@ function dws_tpl_deactivate(): void { /** * Declares whether the plugin works with WooCommerce's order tables, which each plugin built from the template sets from its own evidence. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ diff --git a/includes/_disabled-example.php b/includes/_disabled-example.php index 1e732e3..9791cfa 100644 --- a/includes/_disabled-example.php +++ b/includes/_disabled-example.php @@ -2,8 +2,8 @@ /** * Stays unloaded, because functions.php skips the files in includes/ whose names start with an underscore. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ diff --git a/includes/assets.php b/includes/assets.php index 24bd58c..5dd0fb0 100644 --- a/includes/assets.php +++ b/includes/assets.php @@ -2,8 +2,8 @@ /** * Registers the plugin's built scripts and styles with the dependencies and version wp-scripts generates beside each. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -15,8 +15,8 @@ /** * Returns the payload wp-scripts generates beside a built file, which lists its dependencies, script module imports included, and its version. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $path The built file's path, relative to the plugin's directory. * @@ -36,8 +36,8 @@ function dws_tpl_get_asset( string $path ): array { /** * Registers a built script with its generated dependencies and version, and loads its translations from languages/. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param non-empty-string $handle The script's handle. * @param non-empty-string $path The built script's path, relative to the plugin's directory. @@ -58,8 +58,8 @@ function dws_tpl_register_script( string $handle, string $path, array $args = ar /** * Registers a built stylesheet with its generated version, and its right-to-left variant for right-to-left languages. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param non-empty-string $handle The stylesheet's handle. * @param non-empty-string $path The built stylesheet's path, relative to the plugin's directory. diff --git a/includes/footprint.php b/includes/footprint.php index 8bf8a05..dac6f3c 100644 --- a/includes/footprint.php +++ b/includes/footprint.php @@ -2,8 +2,8 @@ /** * Declares the data the plugin stores, which uninstall removes where the site consents. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -15,8 +15,8 @@ /** * Returns the plugin's stored data by kind, which covers every option, transient, user meta key and scheduled job under the plugin's prefix. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return array{option_prefixes: list, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} */ diff --git a/includes/metadata.php b/includes/metadata.php index 1ffc6ef..76ec661 100644 --- a/includes/metadata.php +++ b/includes/metadata.php @@ -4,8 +4,8 @@ * * A peer calls them from plugins_loaded on, since it may load before the plugin, and behind function_exists(), since a site below the floors or with a partial build may leave them undefined. Each reads only the plugin's header, so a getter that is defined still answers when a later include fails. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -15,8 +15,8 @@ /** * Returns the plugin's slug, which is the text domain its header declares. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return string */ @@ -27,8 +27,8 @@ function dws_tpl_get_plugin_slug(): string { /** * Returns the plugin's name from its header, translated only once init has fired, because WordPress flags translations loaded earlier. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return string */ @@ -41,8 +41,8 @@ function dws_tpl_get_plugin_name(): string { /** * Returns the plugin's version from its header. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return string */ diff --git a/includes/migrations.php b/includes/migrations.php index e9b6fc6..99b88e7 100644 --- a/includes/migrations.php +++ b/includes/migrations.php @@ -2,8 +2,8 @@ /** * Holds the plugin's migration steps, which bring its stored data to the revision its code expects. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -13,8 +13,8 @@ /** * Returns the migration steps, append-only, where index 0 brings the data to revision 1. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return list */ @@ -27,8 +27,8 @@ function dws_tpl_get_migration_steps(): array { /** * Stores the defaults of the settings that need no translation, autoloaded, so a site that never saved them reads them without a query; a translated default is never stored, because it would keep the language the site had when the step ran. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @throws RuntimeException Thrown when a default could not be stored. * diff --git a/includes/options.php b/includes/options.php index 45a790e..271ccba 100644 --- a/includes/options.php +++ b/includes/options.php @@ -2,8 +2,8 @@ /** * Holds the plugin's option keys and defaults, and reads each option as its type, guarding the stored value. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -15,8 +15,8 @@ /** * The option that holds 'yes' where the site consents to uninstall removing the plugin's data. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -25,8 +25,8 @@ /** * The option that holds 'yes' where the site greets returning customers on their account dashboard. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -35,8 +35,8 @@ /** * The option that holds the greeting. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -45,8 +45,8 @@ /** * The option that holds the number of orders a customer places before the greeting shows. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -55,8 +55,8 @@ /** * The value each option reads as while nothing valid is stored. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var array> */ @@ -69,8 +69,8 @@ /** * Returns an option as a boolean, which reads checkbox values such as 'yes' and 'no'. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * @@ -85,8 +85,8 @@ function dws_tpl_get_bool_option( string $key ): bool { /** * Returns an option as an integer. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * @@ -101,8 +101,8 @@ function dws_tpl_get_int_option( string $key ): int { /** * Returns an option as a string, reading a missing or empty value of an option with a translated default as that default, only after init. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * @@ -122,8 +122,8 @@ function dws_tpl_get_string_option( string $key ): string { /** * Returns an option as a list of strings, keeping the stored strings in order. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * @@ -140,8 +140,8 @@ function dws_tpl_get_list_option( string $key ): array { /** * Returns an option's translated default, which exists only after init, or an empty string for an option without one. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * @@ -159,8 +159,8 @@ function dws_tpl_get_option_text( string $key ): string { /** * Returns the number of orders before the greeting shows in the form WooCommerce stores, or an error when the submission is not a whole number of at least 1. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param mixed $submitted The submitted value. * @@ -175,8 +175,8 @@ function dws_tpl_validate_greeting_orders( $submitted ) { /** * Returns the option's default, or null for an option without one. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * diff --git a/includes/safeguards.php b/includes/safeguards.php index fe2b6e3..b6bbfc7 100644 --- a/includes/safeguards.php +++ b/includes/safeguards.php @@ -2,8 +2,8 @@ /** * Holds the plugin's safeguards, which load before its floor gate, so they hold on a runtime below its floors and when the rest of the build fails to load. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ diff --git a/src/Blocks.php b/src/Blocks.php index d03e8e8..5bdafd2 100644 --- a/src/Blocks.php +++ b/src/Blocks.php @@ -9,8 +9,8 @@ /** * Registers the plugin's blocks from their build manifest, and the block-editor script that adds their styles. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Blocks implements ComponentInterface { // region INHERITED METHODS @@ -18,8 +18,8 @@ final class Blocks implements ComponentInterface { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -34,8 +34,8 @@ public function register_hooks(): void { /** * Registers every built block from the manifest, which reads all their metadata from one file. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -46,8 +46,8 @@ public function register_blocks(): void { /** * Enqueues the block-editor script. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ diff --git a/src/Feature/Feature.php b/src/Feature/Feature.php index 31e3903..37fc224 100644 --- a/src/Feature/Feature.php +++ b/src/Feature/Feature.php @@ -10,8 +10,8 @@ /** * Groups the greeting's components, which load only on sites that switch the greeting on. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Feature implements CompositeComponentInterface, ConditionalComponentInterface { // region INHERITED METHODS @@ -19,8 +19,8 @@ final class Feature implements CompositeComponentInterface, ConditionalComponent /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public static function should_load(): bool { @@ -30,8 +30,8 @@ public static function should_load(): bool { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public static function get_child_component_classes(): array { @@ -41,8 +41,8 @@ public static function get_child_component_classes(): array { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void {} diff --git a/src/Feature/FeatureDetail.php b/src/Feature/FeatureDetail.php index 58b63bf..be3f1d4 100644 --- a/src/Feature/FeatureDetail.php +++ b/src/Feature/FeatureDetail.php @@ -10,8 +10,8 @@ /** * Greets customers on their account dashboard once they have placed the set number of orders. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class FeatureDetail implements ComponentInterface { // region INHERITED METHODS @@ -19,8 +19,8 @@ final class FeatureDetail implements ComponentInterface { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -35,8 +35,8 @@ public function register_hooks(): void { /** * Prints the greeting as a WooCommerce notice to a customer whose orders reach the set number. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -52,8 +52,8 @@ public function print_greeting(): void { /** * Enqueues the greeting's stylesheet on the account dashboard. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ diff --git a/src/Plugin.php b/src/Plugin.php index d942889..d82d9c6 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -18,8 +18,8 @@ /** * Composes the plugin from its logger, scheduler, migrations and components, inside the framework's boot boundary. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Plugin { // region FIELDS AND CONSTANTS @@ -27,8 +27,8 @@ final class Plugin { /** * The plugin's slug, which is also its text domain and its log source. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -37,8 +37,8 @@ final class Plugin { /** * The prefix of the plugin's hook names, options, transients and scheduled jobs. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -47,8 +47,8 @@ final class Plugin { /** * The top-level components, in hook-registration order. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var list */ @@ -63,8 +63,8 @@ final class Plugin { /** * The boot boundary, whose status tells whether the plugin booted. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var PluginBoot */ @@ -73,8 +73,8 @@ final class Plugin { /** * The scheduler, which registers its WP-Cron schedules even while the plugin is blocked. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var Scheduler */ @@ -83,8 +83,8 @@ final class Plugin { /** * The migration runner, once the composition built it. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var MigrationRunner|null */ @@ -93,8 +93,8 @@ final class Plugin { /** * The kernel, once the composition built it, which tells which components loaded and which were gated off. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var PluginKernel|null */ @@ -103,8 +103,8 @@ final class Plugin { /** * The logger every part of the plugin writes to. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var PsrLoggerInterface */ @@ -117,8 +117,8 @@ final class Plugin { /** * Constructor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ public function __construct() { $this->logger = new WooCommerceLogger( self::SLUG, \defined( 'DWS_TPL_DEBUG' ) && true === \constant( 'DWS_TPL_DEBUG' ) ? 'debug' : null ); @@ -133,8 +133,8 @@ public function __construct() { /** * Runs the composition inside the framework's boot boundary. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -145,8 +145,8 @@ public function boot(): void { /** * Unschedules the footprint's jobs on the current site, logging each one that could not be unscheduled. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -175,8 +175,8 @@ public function deactivate(): void { /** * Builds the plugin and boots its components, returning false when WooCommerce is missing or too old. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return bool */ @@ -198,8 +198,8 @@ protected function compose(): bool { /** * Returns the container's definitions of the services the root builds, which the kernel's components share. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param PsrLoggerInterface $logger The plugin's logger. * @param Scheduler $scheduler The plugin's scheduler. diff --git a/src/Settings.php b/src/Settings.php index 533a523..60ba651 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -11,8 +11,8 @@ /** * Adds the plugin's WooCommerce settings tab, which holds the greeting's settings and the consent to delete the plugin's data on uninstall. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Settings implements ComponentInterface, SectionFieldsDefinitionInterface { // region INHERITED METHODS @@ -20,8 +20,8 @@ final class Settings implements ComponentInterface, SectionFieldsDefinitionInter /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -31,8 +31,8 @@ public function register_hooks(): void { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_section_fields( string $tab_id, string $section_id ): array { @@ -99,8 +99,8 @@ public function get_section_fields( string $tab_id, string $section_id ): array /** * Adds the plugin's settings tab, which WooCommerce's settings page class must exist to construct. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param array $pages The settings tabs. * diff --git a/uninstall.php b/uninstall.php index 22e437d..a470ee4 100644 --- a/uninstall.php +++ b/uninstall.php @@ -2,8 +2,8 @@ /** * Removes the plugin's data where the site consents, through the framework's uninstall engine, once the site meets the plugin's floors. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ From bd986e4e178d45befac84b39bd5b8b5ed45da2ac Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:14:42 +0100 Subject: [PATCH 13/19] Rehearse the fill-in at the longest prefix it accepts Quality rehearsed the fill-in only with dws_ao, while the script accepts a segment of up to 28 characters, so nothing showed that a prefix near the cap seeds a plugin whose first composer lint:php fails: its longest option constants push their array arrows past the column up to which PHPCS aligns them. The rehearsal fills in the scaffold with a 28-character segment, the longest the script accepts. Its PHPCS run fails until the cap lowers. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/quality.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 53cb5c5..af85577 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -85,7 +85,8 @@ jobs: PLUGIN_NAME: Acme Orders SLUG: acme-orders NS: AcmeOrders - PREFIX: dws_ao + # The longest prefix the fill-in accepts, so the filled-in plugin's PHPCS run shows where the cap really sits. + PREFIX: dws_acmeextraordinaryordermanage PORT: '8821' REPO: acme/acme-orders run: .github/scripts/fill-in-scaffold.sh From aa1bc872fe85227e9e3dd3cbbcbaab2da8555e7e Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:16:27 +0100 Subject: [PATCH 14/19] Lower the prefix cap to the longest that passes PHPCS The fill-in accepted a segment of up to 28 characters after dws_, a cap taken from the admin notices' 32-character limit, but from 24 characters on, the template's longest option constants push their array arrows past the column up to which PHPCS aligns them, so the filled-in plugin's first composer lint:php failed. The cap is 23 characters, the longest whose filled-in plugin passes, in the script's check, its message and the README, and Quality rehearses the fill-in at that cap, so a longer constant fails CI until the cap follows it. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 7 ++++--- .github/workflows/quality.yml | 4 ++-- README.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index 012fa95..0f23972 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -8,9 +8,10 @@ set -euo pipefail # The slug names the entry file and the text domain, so it stays clean kebab-case. grep -Eq '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' <<< "$SLUG" || { echo "::error::plugin-slug must be kebab-case starting with a letter, with no leading, trailing or double hyphen."; exit 1; } grep -Eq '^[A-Za-z][A-Za-z0-9]*$' <<< "$NS" || { echo "::error::php-namespace must match ^[A-Za-z][A-Za-z0-9]*$."; exit 1; } -# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's, and the -# framework's admin notices refuse a prefix over 32 characters or with a network or hidden segment. -{ grep -Eq '^dws_[a-z0-9]{1,28}$' <<< "$PREFIX" && [ "$PREFIX" != dws_network ] && [ "$PREFIX" != dws_hidden ]; } || { echo "::error::prefix must match ^dws_[a-z0-9]{1,28}$ and must not be dws_network or dws_hidden."; exit 1; } +# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's; past 23 characters +# the template's longest option constants fail PHPCS's array alignment, which Quality's rehearsal at the cap checks, +# and the framework's admin notices refuse a network or hidden segment. +{ grep -Eq '^dws_[a-z0-9]{1,23}$' <<< "$PREFIX" && [ "$PREFIX" != dws_network ] && [ "$PREFIX" != dws_hidden ]; } || { echo "::error::prefix must match ^dws_[a-z0-9]{1,23}$ and must not be dws_network or dws_hidden."; exit 1; } # The ports land in JSON numbers, where a leading zero is invalid, and the tests and multisite environments take the # next two; none may be the template's own 8811, 8812 or 8813, which the leftover check below refuses. { grep -Eq '^[1-9][0-9]{0,4}$' <<< "$PORT" && [ "$PORT" -le 65533 ] && { [ "$PORT" -lt 8809 ] || [ "$PORT" -gt 8813 ]; }; } || { echo "::error::wp-env-port must be 1-65533 with no leading zero, and neither it nor the next two ports may be 8811, 8812 or 8813."; exit 1; } diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index af85577..29ce100 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -85,8 +85,8 @@ jobs: PLUGIN_NAME: Acme Orders SLUG: acme-orders NS: AcmeOrders - # The longest prefix the fill-in accepts, so the filled-in plugin's PHPCS run shows where the cap really sits. - PREFIX: dws_acmeextraordinaryordermanage + # The rehearsal fills in the longest prefix the script accepts, so its PHPCS run fails once the cap stops passing. + PREFIX: dws_acmeextraordinaryorders PORT: '8821' REPO: acme/acme-orders run: .github/scripts/fill-in-scaffold.sh diff --git a/README.md b/README.md index b5685c7..522774c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ The workflow: ## Naming - A WooCommerce plugin's display name reads `X for WooCommerce`, as WooCommerce's trademark guidelines ask, and its slug ends in `-for-woocommerce`. -- The prefix is `dws_` and one segment of up to 28 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's prefix starts with another's; the framework's admin notices refuse `dws_network` and `dws_hidden`; a WooCommerce plugin's segment ends in `wc`. +- The prefix is `dws_` and one segment of up to 23 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's prefix starts with another's and the template's longest constants still pass PHPCS; the framework's admin notices refuse `dws_network` and `dws_hidden`; a WooCommerce plugin's segment ends in `wc`. - The namespace is flat: `DeepWebSolutions\\`, with no segment in between. - The repository is `wc-` for a WooCommerce plugin and `wp-` otherwise, as the plugin mirrors are named, and the fill-in names the Composer package `/` and the npm package `@/` after it. - Each plugin takes its own three consecutive wp-env ports, for its development, tests and multisite sites, apart from 8888 and 8889, which wp-env uses by default, and the template's own 8811, 8812 and 8813. From 84bb4214b4135c2e7324d96d2de850d6371f5bdb Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:18:24 +0100 Subject: [PATCH 15/19] Fill in with a read-only token and push from a job of its own The fill-in job held a token that can push while it ran npm ci and wp-scripts format, which execute the locked npm packages' code, and the token sat on disk twice: in the checkout's git config, and in Composer's auth.json, where setup-php writes it so the script's composer update --lock can reach GitHub's API. A compromised package could push to the new plugin's repository. The fill-in job checks out without credentials, runs on the workflow's read-only token and uploads its staged change as a binary patch. A push job, the only one granted contents: write, checks out, applies the patch, commits and pushes, running nothing but git. The script's comment gives install scripts' real reason to stay off. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 2 +- .github/workflows/fill-in-scaffold.yml | 38 ++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index 0f23972..1bea7f5 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -68,7 +68,7 @@ for swept_json in .wp-env.json .wp-env.tests.json .wp-env.multisite.json compose done # A name or slug shorter than the template's leaves wrapped lines that Prettier joins, so the swept scripts are formatted -# again. Install scripts stay off, because the fill-in job holds a token that can push. +# again. Install scripts stay off, since formatting needs none of them. npm ci --ignore-scripts --no-audit --no-fund node_modules/.bin/wp-scripts format '{blocks/src,assets/src,tests/EndToEnd}/**/*.{js,jsx,ts,tsx}' diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index 2719beb..18fbac3 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -34,14 +34,12 @@ jobs: if: ${{ !github.event.repository.is_template }} runs-on: ubuntu-latest timeout-minutes: 10 - permissions: - contents: write steps: - # The job pushes the filled-in plugin back, so the checkout keeps its credentials. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - persist-credentials: true + persist-credentials: false + # setup-php hands its token to Composer on disk, where the npm packages the fill-in runs can read it, so this job's token is read-only. - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.5' @@ -62,12 +60,42 @@ jobs: REPO: ${{ github.repository }} run: .github/scripts/fill-in-scaffold.sh + - name: Save the filled-in plugin as a patch + run: | + git add -A + git diff --cached --binary > "$RUNNER_TEMP/fill-in.patch" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: fill-in + path: ${{ runner.temp }}/fill-in.patch + if-no-files-found: error + retention-days: 1 + + push: + name: Commit and push + needs: fill-in + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write # Pushes the filled-in plugin. + steps: + # Only git runs in this job, so the checkout keeps the credentials the push uses. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: true + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: fill-in + path: ${{ runner.temp }} + - name: Commit and push env: SLUG: ${{ inputs.plugin-slug }} run: | + git apply --index "$RUNNER_TEMP/fill-in.patch" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A git commit -m "Fill in scaffold: $SLUG" git push From a7624294de50e65616f78d1e6ad706235ae5cde4 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:20:47 +0100 Subject: [PATCH 16/19] Refuse fill-in inputs the generated plugin cannot carry The fill-in checked each input with grep, which matches line by line, so a value with a line break passed if one of its lines did; it accepted a display name of spaces only, which WordPress skips, and a namespace that is a reserved word, such as List, which only PHP 8.0 parses in the entry and uninstall.php that must parse on PHP 7.4. The workflows checked the slug they read with a looser pattern than the script, the script wrote its failure idiom five ways, and the leftover port check used \b, which only GNU's regex knows. The script refuses a line break in any input before it edits anything, matches each value whole with bash's own regex, refuses a blank name, and reports every refusal through one fail() helper. The fill-in job and Quality's rehearsal parse the entry, uninstall.php and includes/ on PHP 7.4 before the push. Both workflows check the slug with the script's pattern, whose leading letter the block namespace needs, and the leftover check matches the template's ports as whole words. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 32 +++++++++++++++++++------- .github/workflows/fill-in-scaffold.yml | 15 ++++++++++++ .github/workflows/quality.yml | 13 ++++++++++- .github/workflows/release.yml | 2 +- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index 1bea7f5..a3c3f52 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -5,18 +5,34 @@ set -euo pipefail : "${PLUGIN_NAME:?}" "${SLUG:?}" "${NS:?}" "${PREFIX:?}" "${PORT:?}" "${REPO:?}" -# The slug names the entry file and the text domain, so it stays clean kebab-case. -grep -Eq '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' <<< "$SLUG" || { echo "::error::plugin-slug must be kebab-case starting with a letter, with no leading, trailing or double hyphen."; exit 1; } -grep -Eq '^[A-Za-z][A-Za-z0-9]*$' <<< "$NS" || { echo "::error::php-namespace must match ^[A-Za-z][A-Za-z0-9]*$."; exit 1; } +fail() { + echo "::error::$1" + exit 1 +} + +# Each input lands on one line of the files it fills in, so the checks below match whole values. +for input in "$PLUGIN_NAME" "$SLUG" "$NS" "$PREFIX" "$PORT"; do + [[ $input != *[$'\r\n']* ]] || fail 'No input may contain a line break.' +done + +# The slug names the entry file, the text domain and the block namespace, which WordPress refuses unless it starts +# with a letter, and the workflows check the slug they read from composer.json with the same pattern. +[[ $SLUG =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || fail 'plugin-slug must be kebab-case starting with a letter, with no leading, trailing or double hyphen.' +# A namespace that is a reserved word, such as List, parses from PHP 8.0 only, which the fill-in job's PHP 7.4 syntax +# check refuses. +[[ $NS =~ ^[A-Za-z][A-Za-z0-9]*$ ]] || fail 'php-namespace must match ^[A-Za-z][A-Za-z0-9]*$.' # One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's; past 23 characters # the template's longest option constants fail PHPCS's array alignment, which Quality's rehearsal at the cap checks, # and the framework's admin notices refuse a network or hidden segment. -{ grep -Eq '^dws_[a-z0-9]{1,23}$' <<< "$PREFIX" && [ "$PREFIX" != dws_network ] && [ "$PREFIX" != dws_hidden ]; } || { echo "::error::prefix must match ^dws_[a-z0-9]{1,23}$ and must not be dws_network or dws_hidden."; exit 1; } +[[ $PREFIX =~ ^dws_[a-z0-9]{1,23}$ && $PREFIX != dws_network && $PREFIX != dws_hidden ]] || fail 'prefix must match ^dws_[a-z0-9]{1,23}$ and must not be dws_network or dws_hidden.' # The ports land in JSON numbers, where a leading zero is invalid, and the tests and multisite environments take the # next two; none may be the template's own 8811, 8812 or 8813, which the leftover check below refuses. -{ grep -Eq '^[1-9][0-9]{0,4}$' <<< "$PORT" && [ "$PORT" -le 65533 ] && { [ "$PORT" -lt 8809 ] || [ "$PORT" -gt 8813 ]; }; } || { echo "::error::wp-env-port must be 1-65533 with no leading zero, and neither it nor the next two ports may be 8811, 8812 or 8813."; exit 1; } -# The display name lands in single-quoted PHP and JavaScript strings, and in JSX text. -if printf '%s' "$PLUGIN_NAME" | grep -q "['\\\$/<>{}]"; then echo "::error::plugin-name must not contain \\, \$, /, ', <, >, { or }."; exit 1; fi +[[ $PORT =~ ^[1-9][0-9]{0,4}$ && $PORT -le 65533 && ( $PORT -lt 8809 || $PORT -gt 8813 ) ]] || + fail 'wp-env-port must be 1-65533 with no leading zero, and neither it nor the next two ports may be 8811, 8812 or 8813.' +# WordPress skips a plugin whose name is blank, and the name lands in single-quoted PHP and JavaScript strings, and in +# JSX text. +name_breakers="['\\\$/<>{}]" +[[ $PLUGIN_NAME =~ [^[:space:]] && ! $PLUGIN_NAME =~ $name_breakers ]] || fail "plugin-name must not be blank or contain \\, \$, /, ', <, >, { or }." export SCREAMING_PREFIX="${PREFIX^^}" export TESTS_PORT=$(( PORT + 1 )) @@ -77,7 +93,7 @@ if git grep -n -I -E 'dws-plugin-template|dws_tpl|DWS_TPL|PluginTemplate|DWS Plu echo "::error::The fill-in left the template's names above." status=1 fi -if git grep -n -I -E '\b881[123]\b' -- . ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json'; then +if git grep -n -I -w -E '881[123]' -- . ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json'; then echo "::error::The fill-in left the template's ports above." status=1 fi diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index 18fbac3..f1d593e 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -60,6 +60,21 @@ jobs: REPO: ${{ github.repository }} run: .github/scripts/fill-in-scaffold.sh + # The entry runs before the floor gate and names the namespace, so one PHP 7.4 cannot parse stops the fill-in here, on the files Quality parses on PHP 7.4. + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '7.4' + coverage: none + tools: none + + - name: Check the PHP 7.4 syntax + env: + SLUG: ${{ inputs.plugin-slug }} + run: | + for file in "$SLUG.php" uninstall.php includes/*.php; do + php -l "$file" + done + - name: Save the filled-in plugin as a patch run: | git add -A diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 29ce100..2393a73 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -45,7 +45,7 @@ jobs: id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." exit 1 fi @@ -102,3 +102,14 @@ jobs: npm run build npm run lint:scripts npm run lint:types + # Setting up PHP 7.4 replaces the PHP 8.5 the steps above need, so the fill-in job's last check runs last here too. + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '7.4' + coverage: none + tools: none + - name: Check the PHP 7.4 syntax + run: | + for file in acme-orders.php uninstall.php includes/*.php; do + php -l "$file" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 121e1fa..e6285d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]]; then echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." exit 1 fi From 13c1025448313580e8a3c977eda445da5823156d Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:22:14 +0100 Subject: [PATCH 17/19] Fill in the plugin's description with its identity The fill-in replaced the template's names but not its description, so every new plugin showed the template's sentence on the Plugins screen and in composer.json until its author noticed a TODO in the README, and package.json carried yet another sentence, so no single substitution could reach all three. fill-in-scaffold.yml takes a description input, which the script refuses when blank or holding */, which would close the entry's header docblock, or a line break. It replaces the template's sentence, read from composer.json, in the entry's Description header and in composer.json's and package.json's description, escaped for JSON, and the scaffold README's opening TODO with it, and its leftover check fails on the template's sentence. package.json's description is that sentence too. Quality rehearses a description with quotes and a backslash, and the README lists the input and its places. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 27 ++++++++++++++++++++++---- .github/workflows/fill-in-scaffold.yml | 5 +++++ .github/workflows/quality.yml | 2 ++ README.md | 5 +++-- package.json | 2 +- 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index a3c3f52..740352c 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -3,16 +3,17 @@ # generated from the template, and Quality rehearses it on the template with sample inputs. set -euo pipefail -: "${PLUGIN_NAME:?}" "${SLUG:?}" "${NS:?}" "${PREFIX:?}" "${PORT:?}" "${REPO:?}" +: "${PLUGIN_NAME:?}" "${DESCRIPTION:?}" "${SLUG:?}" "${NS:?}" "${PREFIX:?}" "${PORT:?}" "${REPO:?}" fail() { echo "::error::$1" exit 1 } -# Each input lands on one line of the files it fills in, so the checks below match whole values. -for input in "$PLUGIN_NAME" "$SLUG" "$NS" "$PREFIX" "$PORT"; do - [[ $input != *[$'\r\n']* ]] || fail 'No input may contain a line break.' +# Each input lands on one line of a PHP, JSON or Markdown file, where a control character breaks it, so the checks +# below match whole values. +for input in "$PLUGIN_NAME" "$DESCRIPTION" "$SLUG" "$NS" "$PREFIX" "$PORT"; do + [[ $input != *[[:cntrl:]]* ]] || fail 'No input may contain a line break or another control character.' done # The slug names the entry file, the text domain and the block namespace, which WordPress refuses unless it starts @@ -33,6 +34,8 @@ done # JSX text. name_breakers="['\\\$/<>{}]" [[ $PLUGIN_NAME =~ [^[:space:]] && ! $PLUGIN_NAME =~ $name_breakers ]] || fail "plugin-name must not be blank or contain \\, \$, /, ', <, >, { or }." +# The description lands in the entry's header docblock, which */ would close. +[[ $DESCRIPTION =~ [^[:space:]] && $DESCRIPTION != *'*/'* ]] || fail 'description must not be blank or contain */.' export SCREAMING_PREFIX="${PREFIX^^}" export TESTS_PORT=$(( PORT + 1 )) @@ -40,6 +43,8 @@ export MULTISITE_PORT=$(( PORT + 2 )) export REPO_NAME="${REPO#*/}" # Composer and npm accept only lowercase package names, which the sweep takes from the repository. export REPO="${REPO,,}" +TEMPLATE_DESCRIPTION=$(php -r 'echo json_decode( (string) file_get_contents( "composer.json" ), false, 512, JSON_THROW_ON_ERROR )->description;') +export TEMPLATE_DESCRIPTION git mv --force README.scaffold.md README.md # The scaffold README becomes the plugin's README.md, which .distignore already excludes. @@ -70,6 +75,16 @@ git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold. BEGIN { @ARGV = grep { ! -l $_ } @ARGV } s/\b(881[123])\b/8811 == $1 ? $ENV{PORT} : 8812 == $1 ? $ENV{TESTS_PORT} : $ENV{MULTISITE_PORT}/ge; ' + +# The description goes in after the port sweep, which would rewrite a port number in it. +# shellcheck disable=SC2016 +perl -pi -e 's/\Q$ENV{TEMPLATE_DESCRIPTION}\E/$ENV{DESCRIPTION}/' "$SLUG.php" +# The manifests hold the description in JSON strings, so their copy escapes quotes and backslashes. +# shellcheck disable=SC2016 +perl -pi -e 'BEGIN { ( $description = $ENV{DESCRIPTION} ) =~ s/(["\\])/\\$1/g } s/\Q$ENV{TEMPLATE_DESCRIPTION}\E/$description/' composer.json package.json +# shellcheck disable=SC2016 +perl -pi -e 's/^TODO: one sentence saying why this plugin exists.*$/$ENV{DESCRIPTION}/' README.md + find changelog -type f ! -name '.gitkeep' -delete # The sweep renamed the package, so the lock's content hash follows, or composer validate --strict fails. @@ -93,6 +108,10 @@ if git grep -n -I -E 'dws-plugin-template|dws_tpl|DWS_TPL|PluginTemplate|DWS Plu echo "::error::The fill-in left the template's names above." status=1 fi +if git grep -n -I -F -e "$TEMPLATE_DESCRIPTION" -- . ':!.github/scripts/fill-in-scaffold.sh'; then + echo "::error::The fill-in left the template's description above." + status=1 +fi if git grep -n -I -w -E '881[123]' -- . ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json'; then echo "::error::The fill-in left the template's ports above." status=1 diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index f1d593e..c1750d6 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -7,6 +7,10 @@ on: description: 'Display name, such as "Acme Orders"' required: true type: string + description: + description: 'One sentence saying why the plugin exists, such as "Acme Orders keeps every order a store takes in one place."' + required: true + type: string plugin-slug: description: 'Kebab-case slug, also the text domain and the entry file name, such as "acme-orders"' required: true @@ -53,6 +57,7 @@ jobs: - name: Fill in the placeholders env: PLUGIN_NAME: ${{ inputs.plugin-name }} + DESCRIPTION: ${{ inputs.description }} SLUG: ${{ inputs.plugin-slug }} NS: ${{ inputs.php-namespace }} PREFIX: ${{ inputs.prefix }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2393a73..953c8f3 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -83,6 +83,8 @@ jobs: - name: Fill in the scaffold env: PLUGIN_NAME: Acme Orders + # The quotes and the backslash check that the manifests' JSON strings escape the description. + DESCRIPTION: 'Acme Orders keeps a store''s "orders" in one place, C:\ included.' SLUG: acme-orders NS: AcmeOrders # The rehearsal fills in the longest prefix the script accepts, so its PHPCS run fails once the cap stops passing. diff --git a/README.md b/README.md index 522774c..4dc454e 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,17 @@ Create the plugin's repository from this template with GitHub's **Use this templ | Input | Example | What it sets | | --- | --- | --- | | `plugin-name` | `Acme Orders for WooCommerce` | the display name in the plugin header, the strings and the docs | +| `description` | `Acme Orders keeps every order a store takes in one place.` | the entry's `Description` header, `composer.json`'s and `package.json`'s `description` and the README's opening sentence; set the repository's GitHub description to the same sentence | | `plugin-slug` | `acme-orders-for-woocommerce` | the text domain and the names of the entry file and the release zip | | `php-namespace` | `AcmeOrders` | the namespace segment after `DeepWebSolutions\` | | `prefix` | `dws_aowc` | the prefix of every global function, constant, option and hook | | `wp-env-port` | `8821` | the development site's port; the tests site takes the next one | The workflow: -- renames the entry file and replaces the template's names, prefix and ports in every tracked file outside `.github/workflows/`, which read the slug from `composer.json` instead, since GitHub refuses the workflow's own token for workflow changes; +- renames the entry file and replaces the template's names, description, prefix and ports in every tracked file outside `.github/workflows/`, which read the slug from `composer.json` instead, since GitHub refuses the workflow's own token for workflow changes; - makes `README.scaffold.md` the plugin's README, which lists the template's demo and what removing each part takes; - deletes the disabled example include and any changelog fragments, and refreshes the Composer lock's content hash; -- fails when any name or port of the template is left, and otherwise pushes one commit to the branch it ran on. +- fails when any name, description or port of the template is left, and otherwise pushes one commit to the branch it ran on. ## Naming diff --git a/package.json b/package.json index c6ab20b..1879b51 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ahegyes/wordpress-plugin-template", "private": true, - "description": "DWS WordPress plugin template — wp-env, script and style tooling.", + "description": "WordPress plugins on the wp-framework packages start here, from one tested anatomy, so all are built alike and a fix found in one applies to all.", "license": "GPL-3.0-or-later", "repository": { "type": "git", From e626af6b24a817440a1d0a5a8c99856e27c7cef1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:26:29 +0100 Subject: [PATCH 18/19] Show the list reader in the Payments recipe instead of shipping it dws_tpl_get_list_option() had no caller but its test rows, since no template option holds a list, yet every plugin built from the template inherited it and its rows, and DWS_TPL_OPTION_DEFAULTS' type allowed a list no default used. The reader, its OptionsTest rows and the list type leave the template. The Payments-tab recipe, whose WooCommerce section stores each field in its own option, as the reader reads them, gains a multiselect of the store's payment methods and shows the key, its empty default, the type's widening and the reader as the template shipped it. Its options come from a loop that keeps only payment gateways, which PHPStan types as the field's string map where wp_list_pluck() would not; the recipe passes PHPStan and PHPCS once applied. Assisted-by: Claude Code:claude-opus-5-5 --- docs/recipes.md | 48 ++++++++++++++++++++++++++++++- includes/options.php | 20 +------------ tests/Integration/OptionsTest.php | 16 ----------- 3 files changed, 48 insertions(+), 36 deletions(-) diff --git a/docs/recipes.md b/docs/recipes.md index 9cf42e8..8f54ecc 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -541,6 +541,13 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti */ #[\Override] public function get_section_fields( string $tab_id, string $section_id ): array { + $methods = array(); + foreach ( WC()->payment_gateways()->payment_gateways() as $gateway ) { + if ( $gateway instanceof \WC_Payment_Gateway ) { + $methods[ $gateway->id ] = $gateway->get_method_title(); + } + } + return array( array( 'type' => 'title', @@ -553,6 +560,14 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti 'title' => __( 'Payment notes', 'dws-plugin-template' ), 'desc' => __( 'Add a note to each order when its payment completes', 'dws-plugin-template' ), ), + array( + 'type' => 'multiselect', + 'id' => DWS_TPL_OPTION_PAYMENT_METHODS, + 'title' => __( 'Payment methods', 'dws-plugin-template' ), + 'desc' => __( 'Add the note only to orders paid with these methods', 'dws-plugin-template' ), + 'class' => 'wc-enhanced-select', + 'options' => $methods, + ), array( 'type' => 'sectionend', 'id' => Plugin::PREFIX . '_payment_options', @@ -607,7 +622,38 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti } ``` -WooCommerce stores each field in its own option under its id, flat rather than in a group, and a WooCommerce checkbox stores `yes` or `no`; read it with `dws_tpl_get_bool_option()` once the key and its default are in `includes/options.php`. +WooCommerce stores each field in its own option under its id, flat rather than in a group: a checkbox stores `yes` or `no`, which `dws_tpl_get_bool_option()` reads, and a multiselect the list of the chosen keys, which a list reader reads. Add each key to `includes/options.php` with its default in `DWS_TPL_OPTION_DEFAULTS`, the multiselect's an empty array, whose `@var` then allows `list`, and the list reader beside the other typed readers: + +```php +/** + * The option that holds the payment methods whose orders get the payment note. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ +const DWS_TPL_OPTION_PAYMENT_METHODS = 'dws_tpl_payment_methods'; + +/** + * Returns an option as a list of strings, keeping the stored strings in order. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The option's key. + * + * @return list + */ +function dws_tpl_get_list_option( string $key ): array { + $default = dws_tpl_get_option_default( $key ); + $stored = get_option( $key, $default ); + $stored = is_array( $stored ) ? $stored : $default; + return is_array( $stored ) ? array_values( array_filter( $stored, 'is_string' ) ) : array(); +} +``` + +WooCommerce's save drops a multiselect key of `'0'` or `''`, so each key stays truthy, as gateway ids are. ## Recurring jobs diff --git a/includes/options.php b/includes/options.php index 271ccba..0078f7b 100644 --- a/includes/options.php +++ b/includes/options.php @@ -58,7 +58,7 @@ * @since 1.0.0 * @version 1.0.0 * - * @var array> + * @var array */ const DWS_TPL_OPTION_DEFAULTS = array( DWS_TPL_OPTION_DELETE_DATA => false, @@ -119,24 +119,6 @@ function dws_tpl_get_string_option( string $key ): string { return is_string( $stored ) ? $stored : ( is_string( $default ) ? $default : '' ); } -/** - * Returns an option as a list of strings, keeping the stored strings in order. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $key The option's key. - * - * @return list - */ -function dws_tpl_get_list_option( string $key ): array { - $default = dws_tpl_get_option_default( $key ); - $stored = get_option( $key, $default ); - $stored = is_array( $stored ) ? $stored : $default; - - return is_array( $stored ) ? array_values( array_filter( $stored, 'is_string' ) ) : array(); -} - /** * Returns an option's translated default, which exists only after init, or an empty string for an option without one. * diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php index 49ae8fe..438a245 100644 --- a/tests/Integration/OptionsTest.php +++ b/tests/Integration/OptionsTest.php @@ -50,15 +50,6 @@ public static function stored_strings(): array { ); } - public static function stored_lists(): array { - return array( - 'list' => array( array( 'a', 'b' ), array( 'a', 'b' ) ), - 'mixed' => array( array( 'a', 7, 'b' ), array( 'a', 'b' ) ), - 'keyed' => array( array( 'x' => 'a' ), array( 'a' ) ), - 'string' => array( 'a', array() ), - ); - } - public static function submitted_order_counts(): array { return array( 'one' => array( '1', '1' ), @@ -120,13 +111,6 @@ public function test_a_string_reads_a_stored_string_or_the_empty_string( mixed $ self::assertSame( $expected, dws_tpl_get_string_option( self::KEY ) ); } - #[DataProvider( 'stored_lists' )] - public function test_a_list_reads_the_stored_strings_in_order( mixed $stored, array $expected ): void { - update_option( self::KEY, $stored ); - - self::assertSame( $expected, dws_tpl_get_list_option( self::KEY ) ); - } - public function test_a_string_without_a_stored_value_reads_its_translated_default(): void { delete_option( DWS_TPL_OPTION_GREETING ); From b1c6bd6eda0fea3832da2ca931cb4e51bd073955 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:41:04 +0100 Subject: [PATCH 19/19] Read the WordPress floor from the entry's header PHPStan's floor was a second copy of the entry's Requires at least, held to the header by a regex Unit row, so raising the floor meant editing two files, while wp-compat can read the header itself. phpstan.dist.neon names the entry as WPCompat.pluginFile and sets requiresAtLeast to null, which overrides configs' value, so wp-compat reads the floor from the header; with the header at 6.0, PHPStan reports a call that WordPress 6.8 added. The Unit row goes, its last row inlines the header read, and AGENTS.md and tests/README.md follow. PHPStan's local result cache does not track the entry, so a run right after a header-only change may reuse results until the cache clears; CI starts without one. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- phpstan.dist.neon | 5 +++-- tests/README.md | 2 +- tests/Unit/EntryHeadersTest.php | 14 +++----------- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a50440..580dbff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru 6. **Options and data.** Options are read only through `includes/options.php`'s typed readers, safeguards excepted. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else its own entry. 7. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, or guard them, which `phpstan.safeguards.neon` checks. 8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. -9. **WordPress floor.** The plugin calls only APIs its `Requires at least` has, PHP's and JavaScript's alike: `phpstan.dist.neon`'s `WPCompat.requiresAtLeast` follows the header, which a Unit row pins, and CI runs the end-to-end suite on the floor's WordPress, which supplies the `@wordpress/*` scripts at runtime. +9. **WordPress floor.** The plugin calls only APIs its `Requires at least` has, PHP's and JavaScript's alike: PHPStan's wp-compat reads the floor from that header, and CI runs the end-to-end suite on the floor's WordPress, which supplies the `@wordpress/*` scripts at runtime. ## Style diff --git a/phpstan.dist.neon b/phpstan.dist.neon index d9b301d..2f49558 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -23,6 +23,7 @@ parameters: # The scoped packages load through vendor-prefixed/scoper-autoload.php, which PHPStan does not read. scanDirectories: - vendor-prefixed - # The entry's Requires at least, so no call reaches an API newer than the plugin's WordPress floor. + # wp-compat reads the floor from the entry's Requires at least only while requiresAtLeast, which configs' profile sets, is null. WPCompat: - requiresAtLeast: '7.1' + pluginFile: dws-plugin-template.php + requiresAtLeast: null diff --git a/tests/README.md b/tests/README.md index 131458c..3a5db9e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,7 +6,7 @@ This document describes the plugin's three test suites, the environments they ru | Suite | Where | Runs on | Covers | | --- | --- | --- | --- | -| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage, and the entry's headers against the locked WooCommerce and PHPStan's WordPress floor | +| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage, and the entry's `WC tested up to` against the locked WooCommerce | | Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded, and the multisite site for its `multisite` group | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, the peer getters, a partial deploy: a missing build file or engine, or an include that throws, and the boot and settings on a second site of a network | | End to end | `tests/EndToEnd/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, and the plugin with WooCommerce deactivated | diff --git a/tests/Unit/EntryHeadersTest.php b/tests/Unit/EntryHeadersTest.php index 4d7cd5b..c1d41f0 100644 --- a/tests/Unit/EntryHeadersTest.php +++ b/tests/Unit/EntryHeadersTest.php @@ -11,18 +11,10 @@ final class EntryHeadersTest extends TestCase { public function test_wc_tested_up_to_names_the_locked_woocommerce(): void { $lock = \json_decode( (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/composer.lock' ), true, 512, \JSON_THROW_ON_ERROR ); $woocommerce = \array_find( $lock['packages-dev'], static fn ( array $package ): bool => 'wp-plugin/woocommerce' === $package['name'] ); + $entry = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ); self::assertIsArray( $woocommerce ); - self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), self::get_header( 'WC tested up to' ) ); - } - - public function test_phpstan_checks_wordpress_calls_at_the_entry_floor(): void { - self::assertSame( 1, \preg_match( "/^\tWPCompat:\n\t\trequiresAtLeast: '([^']+)'$/m", (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/phpstan.dist.neon' ), $floor ) ); - self::assertSame( self::get_header( 'Requires at least' ), $floor[1] ); - } - - protected static function get_header( string $name ): string { - self::assertSame( 1, \preg_match( '/^\s*\*\s*' . \preg_quote( $name, '/' ) . ':\s*(\S+)\s*$/m', (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ), $header ) ); - return $header[1]; + self::assertSame( 1, \preg_match( '/^\s*\*\s*WC tested up to:\s*(\S+)\s*$/m', $entry, $header ) ); + self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), $header[1] ); } }