Skip to content

Harden the template's boot, build gate and fill-in - #31

Merged
ahegyes merged 19 commits into
trunkfrom
fix/entry-build-gate-and-fill-in
Nov 10, 2026
Merged

ahegyes merged 19 commits into
trunkfrom
fix/entry-build-gate-and-fill-in

Conversation

@ahegyes

@ahegyes ahegyes commented Nov 10, 2026 •

Copy link
Copy Markdown
Owner

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

  • Bug fix
  • New feature
  • Refactor
  • Build / CI / tooling
  • Documentation
  • Dependency update

Checklist

  • Changelog fragment added (composer changelog:add): the template takes no fragments
  • Tests added/updated
  • composer quality-check passes locally
  • Plugin header updated if runtime requirements changed

Breaking changes for forks

  • Boot: dws_tpl_boot() and dws_tpl_deactivate() report only a composition root that cannot be built; a hook listener's throw surfaces as the listener's own error.
  • Build gate: the entry returns early without the bootstrap package, then checks blocks/build/blocks-manifest.php and assets/build/editor.asset.php after the floor gate; a plugin without one of them edits the check.
  • Static analysis: phpstan.safeguards.neon analyses the entry with the safeguards; the main profile scans it and reads the WordPress floor from its Requires at least.
  • Version: the template reads 1.0.0 and the fill-in keeps it; the release publishes only on a tag outside a template repository.
  • Fill-in: a new required description input; 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.
  • Options: dws_tpl_get_list_option() is gone from includes/options.php; the Payments-tab recipe shows it.

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
@ahegyes ahegyes changed the title Fix/entry build gate and fill in Harden the template's boot, build gate and fill-in Nov 10, 2026
@ahegyes
ahegyes merged commit 0043197 into trunk Nov 10, 2026
37 of 38 checks passed
@ahegyes
ahegyes deleted the fix/entry-build-gate-and-fill-in branch November 10, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant