Skip to content

Turn bootstrap into classes, drop shared functions, state the contract - #28

Merged
ahegyes merged 11 commits into
trunkfrom
refactor/structure-audit
Oct 19, 2026
Merged

ahegyes merged 11 commits into
trunkfrom
refactor/structure-audit

Conversation

@ahegyes

@ahegyes ahegyes commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

The framework exposed more API than it meant to. The bootstrap package's helpers were namespaced functions that PHP cannot hide, shared loaded three unrelated helper functions on every request through a Composer files entry, and AGENTS.md never said what the 2.x contract covers.

  • Requirements::check(), Updater::register() and Uninstaller::run() replace the bootstrap functions. Each class is final, its helpers are protected static, and it declares a protected constructor. The classes live in src/, loaded by path and never autoloaded; consumers still require only load.php and uninstall.php, and PHP 7.4 parsing is unchanged.
  • Shared's functions.php and its files entry are gone:
    • core reads stored integers with filter_var( …, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) and requires ext-filter;
    • ErrorLogLogger interpolates placeholders with its own protected method;
    • MigrationRunner's logger fallback writes the raw message, the context as JSON, the exception and the logger's failure.
  • NoticeRecords declares a protected constructor, and AbstractValueObject::are_equal() is private.
  • Every package directory carries an index.php stub.
  • AGENTS.md maps the class form, states the shared admission rule and gains a Contract section.

Affected packages

  • wp-framework-bootstrap
  • wp-framework-shared
  • wp-framework-core
  • wp-framework-settings (index.php stub only)
  • wp-framework-woocommerce (index.php stub only)

Checklist

  • Tests added or updated:
    • Integration: the bootstrap tests call the classes, and GitHubUpdaterTest is now UpdaterTest.
    • Integration: ProvidesLogMessagesTrait runs the interpolation cases through ErrorLogLogger::log(); new cases cover the raw logger fallback and an array stored as the migration revision.
    • Unit: PackageManifestsTest pins ext-filter on core, an index.php in every package directory, and no autoload key for bootstrap.
  • composer quality-check passes locally (Unit 200, Integration 494), plus composer test:integration:multisite (26) and PHP 7.4 php -l over packages/bootstrap
  • Public API changes are documented in the affected package's README.md (the package READMEs stay pointer-form; AGENTS.md carries the file map and the Contract section)
  • Breaking changes are explicitly justified below

Breaking changes / migration notes

No version is tagged yet, so nothing released breaks. Code built on trunk changes:

  • the entry file calls Requirements::check( __FILE__ ) and Updater::register( __FILE__ ) instead of check_requirements() and register_github_updater();
  • the uninstall shim calls Uninstaller::run() instead of uninstall(), with the same arguments;
  • calls to Shared\insert_after(), Shared\interpolate_log_message() or Shared\parse_int() move to native PHP, for example filter_var( …, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) for parse_int().

Every reachable symbol is contract the framework has to keep. The helpers are made unreachable, and shared drops the functions that no second caller needed.

The structure-audit phase opens on the latest of every package. Composer
had nothing to move: every direct dependency is at its latest release and
the lock is unchanged, with configs still at 6302f1f, which is configs'
trunk, so the reusable-workflow pins stay. npm moved two transitive
development packages up: @types/node 26.6.4 to 26.6.5 and strnum 2.4.2 to
2.5.1. No code change was needed.

Assisted-by: Claude Code:claude-opus-5-5
The bootstrap package exposed every helper as a namespaced function.
@internal tags marked them, but PHP cannot hide a function, and
phpstan-no-private misses calls made from class methods, so any helper a
plugin happened to call became API the package had to keep.

Each by-path file now holds a final class with one public static entry
point: Requirements::check(), Updater::register() and Uninstaller::run().
Every helper is a protected static method, the four single-call ones
included, and each class declares a protected constructor, so nothing
else is reachable. The hooks stay static closures, because a named
callback on a static-only class would have to be public. The paths,
load.php and PHP 7.4 parseability are unchanged.

The Integration tests call the classes, and GitHubUpdaterTest is renamed
UpdaterTest after the class it covers.

Assisted-by: Claude Code:claude-opus-5-5
NoticeRecords holds only static methods, yet anyone could construct it,
which made an instance a reachable part of its surface.

The class declares an empty protected constructor, so it cannot be
instantiated from outside, as every static-only framework class does.

Assisted-by: Claude Code:claude-opus-5-5
Core read its stored integers through Shared\parse_int(), a one-line
wrapper around filter_var() that PHPStan already types as int|null. It
added no typing, and it put an extension dependency and a function that
every package could reach into shared.

The six reads in MigrationRunner and the two scheduler backends call
filter_var() with FILTER_VALIDATE_INT and FILTER_NULL_ON_FAILURE
directly; FILTER_NULL_ON_FAILURE keeps an array from reading as false,
which the filter's default option returns. Core declares ext-filter,
which PackageManifestsTest pins, and an Integration case pins that an
array stored as the revision reads as 0 and stops the run with an error.

Assisted-by: Claude Code:claude-opus-5-5
ErrorLogLogger and MigrationRunner's logger fallback interpolated through
Shared\interpolate_log_message(), so a log-formatting detail was API
every package could reach, and the fallback reran formatting code inside
the catch that handles a broken logger.

ErrorLogLogger carries a protected interpolate() with the same rules:
only placeholders the message uses are cast, and a value that throws
while casting leaves its placeholder. Its cases moved from the shared
function's Unit test to a data provider, ProvidesLogMessagesTrait, run
through ErrorLogLogger::log(), so another logger can run the same cases.
The runner's fallback writes the raw message, the context as JSON
without the exception, the exception and the logger's failure; it never
delegates to an ErrorLogLogger, whose qm/{level} action would run again
inside the catch.

Assisted-by: Claude Code:claude-opus-5-5
Shared's functions.php held three unrelated helpers, insert_after(),
interpolate_log_message() and parse_int(), loaded on every request
through a Composer files entry. Each was reachable API with no second
caller that needed it: core reads integers with filter_var(), each
logger interpolates its own placeholders, and every v1 insert_after()
site has a native rewrite.

The file, its files entry and its Unit test are deleted, and shared no
longer requires ext-filter, which PackageManifestsTest pins. The
package's description drops "helper functions".

Assisted-by: Claude Code:claude-opus-5-5
AbstractValueObject is meant to be extended, so its protected
are_equal() was surface every subclass could call and the package would
have to keep. No subclass calls it, and it is private now; the closure
inside it still recurses through self::.

Notice::ID_PATTERN is public on purpose, since consumers build notice
ids that must match it, and its docblock now says so.

Assisted-by: Claude Code:claude-opus-5-5
A package installed where the web server lists directories showed its
file tree, since no package directory had an index file, and nothing
would notice a directory added without one.

Every directory under packages/ carries the stub
`<?php declare( strict_types=1 ); // Silence is golden.`, which PHPCS
accepts, and PackageManifestsTest fails on a package directory that
lacks one.

Assisted-by: Claude Code:claude-opus-5-5
AGENTS.md still mapped the bootstrap package as free functions, shared
as owning functions.php and ext-filter, and settings and woocommerce as
shipping functions.php files. It also let any primitive with one call
site into shared, and it never said what the 2.x contract covers.

The file map names the bootstrap classes and their entry points, core's
ext-filter, the settings and woocommerce function families as final
classes, and WooCommerceUninstaller::get_handlers(). Convention 4 states
the shared admission rule, convention 6 puts shared logic in static
methods of final classes, and convention 10 says each by-path file holds
a final class. A Contract section states what the contract covers, the
README-named internal namespaces and SemVer from the first tag. The
Style section adds the index.php stub, private internals of non-final
classes, the no-free-functions rule with its protected constructors and
static closures, and the @Package tag group.

Assisted-by: Claude Code:claude-opus-5-5
Convention 10 said every by-path file holds a final class, but load.php
only requires its siblings and the index.php stub holds no code, so the
rule read as broken by two files it covers.

It now says the by-path files define code only as final classes with
public static entry points, which every one of them meets.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Refactor/structure audit Turn bootstrap into classes, drop shared functions, state the contract Oct 19, 2026
The bootstrap classes lived in kebab-case files named after their former
functions, so requirements.php held Requirements, against the one class
per ClassName.php rule. Their file docblocks repeated the class
docblocks. And the package root mixed the two paths consumers require
with two files only load.php loads.

The classes live in src/Requirements.php, src/Updater.php and
src/Uninstaller.php, with no file docblock, like every other class file.
The root holds only the two public paths: load.php, which loads
Requirements and Updater, and uninstall.php, which is now a loader for
Uninstaller. src/ is loaded by path, not autoloaded, because Composer's
autoloader requires every package's files entries as it loads, and
PHP-DI's functions.php fails to parse on the PHP 7.4 the requirements
gate has to run on. PackageManifestsTest fails if bootstrap's manifest
gains an autoload key. AGENTS.md maps the new layout and says that only
the loader paths are contract.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes
ahegyes merged commit e51d835 into trunk Oct 19, 2026
16 checks passed
@ahegyes
ahegyes deleted the refactor/structure-audit branch October 19, 2026 09:01
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