Repository navigation
Harden the template's boot, build gate and fill-in - #31
Merged
Merged
Conversation
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The template's boot reported a hook listener's throw as missing files, its build gate checked one of the two build trees the plugin reads, its build-error log named no location and wrote on every request, and PHPStan never checked the entry at the runtimes it must survive. Its fill-in accepted prefixes, names, namespaces and multi-line values the generated plugin could not carry, left the template's description in place, and ran npm code beside a token that could push; the template could publish a release of itself and seeded plugins at 0.1.0. This narrows the boot's catch, gates on both build trees in consumer-a's form, throttles the build-error log, analyses the entry beside the safeguards, hardens and splits the fill-in, seeds 1.0.0, and moves the unused list reader into a recipe.
Type of change
Checklist
composer changelog:add): the template takes no fragmentscomposer quality-checkpasses locallyBreaking changes for forks
dws_tpl_boot()anddws_tpl_deactivate()report only a composition root that cannot be built; a hook listener's throw surfaces as the listener's own error.blocks/build/blocks-manifest.phpandassets/build/editor.asset.phpafter the floor gate; a plugin without one of them edits the check.phpstan.safeguards.neonanalyses the entry with the safeguards; the main profile scans it and reads the WordPress floor from itsRequires at least.descriptioninput; the prefix's segment is at most 23 characters; control characters, a blank name and a namespace PHP 7.4 cannot parse are refused; the push runs in a job of its own.dws_tpl_get_list_option()is gone fromincludes/options.php; the Payments-tab recipe shows it.