fix(bootstrap): register OpenRegister's autoloader before probing for its classes (ADR-040) - #276
Conversation
… its classes
`larpingapp` sorts before `openregister`, and Nextcloud registers apps in
sorted order: OC_App::getEnabledApps() does sort($apps) and
Coordinator::registerApps() walks that list calling
OC_App::registerAutoloading($appId, $path) and then $app->register() for one
app at a time. So OCA\OpenRegister\ is NOT autoloadable inside LarpingApp's own
register() — on a perfectly healthy instance, with OpenRegister enabled.
All three class_exists('OCA\OpenRegister\Event\…') probes in register() were
therefore answering FALSE. Not "not loaded yet" — FALSE, indistinguishable from
OpenRegister being absent. LarpingApp registered NO event listeners at all:
- DeepLinkRegistrationEvent -> unified-search deep links, absent
- ObjectCreatingEvent -> CharacterRequirementListener, absent
- ObjectUpdatingEvent -> CharacterRequirementListener, absent
The last two are the server-authoritative skill-requirement and XP-budget
enforcement on character writes. That enforcement is server-side precisely
because the client cannot be trusted, and it was silently not running — the app
stayed enabled, served normally, and nothing reported the gap.
The same load order was measured directly on the sibling app openbuild (also
sorting before openregister), whose CI logs `OpenRegister AppHost\Bootstrap is
not autoloadable` on every occ call while OpenRegister is installed and enabled.
Fix: put OpenRegister's PSR-4 prefix on the autoloader before the guards.
registerAutoloading() touches only the autoloader and is idempotent.
IAppManager::loadApp() is deliberately NOT used: it marks OpenRegister loaded
and calls Coordinator::bootApp(), booting it before its own register() has run.
The prelude returns false rather than throwing when OpenRegister genuinely is
absent, so the guards still do their job.
Same defect class as hydra gate-64 (apphost-autoload-prelude) / ADR-040, one
namespace over: gate-64 only probes for OCA\OpenRegister\AppHost\ names, so it
does not see this one.
Two CI findings on the prelude, both real: 1. psalm UndefinedClass on \OC_App. It is Nextcloud's server-private legacy bootstrap class, absent from nextcloud/ocp, and there is no OCP interface for registering another app's autoloader. Declared as a suppressed referencedClass in psalm.xml, the same way doriath declares it. 2. The coverage ratchet. `return true` after the call plus `return false` in the catch gave the method a branch that NO environment can exercise both sides of — whichever runs, the other is dead in that run — so the class could never reach full line coverage. No caller ever consumed the return value either: what callers depend on is the class_exists() guard that follows the call. The method is now void with a single statement in the try and a comment-only catch, so every executable line runs in every environment. The tests now assert the two things that are actually observable: that control returns to the caller at all (a Throwable escaping would fail the test, and in production would abort the whole register()), and that a second call does not stack another autoloader. phpmd StaticAccess on the new composition-root call is documented on the calling method rather than baselined.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ❌ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-06 08:11 UTC
Download the full PDF report from the workflow artifacts.
The prelude requirement was appended to an existing legacy spec, which pulls every scenario in that file into gate-19's diff scope and demands e2e coverage for scenarios this change never touched. It is also not the same capability: apphost-adoption / skill-requirement-enforcement describe what the wiring DOES, this describes whether the wiring happens at all. Moved to openspec/specs/apphost-autoload-prelude/spec.md, deliberately with no scenarios: both behaviours live in the app-registration phase, which completes before the first request is dispatched, so neither is reachable from a browser, and the absent-OpenRegister path cannot be set up on an instance that needs OpenRegister to serve the app at all. They are asserted in the unit test named in the spec, so no @e2e exclusion is claimed for either.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ❌ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-06 08:18 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ❌ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-06 08:34 UTC
Download the full PDF report from the workflow artifacts.
The coverage ratchet was right and the code was wrong. Clover for scholiq shows it exactly: line 100 (the registerAutoloading call) count=2, line 101 (the catch) count=0. The catch was never entered — because every instance this suite runs on HAS OpenRegister installed, so getAppPath() never throws. The never-rethrow branch, which is the entire reason this class exists, had never once been executed by a test. register() now takes an optional app id. Production callers pass nothing and get 'openregister'; the new test passes an id that cannot resolve, so getAppPath() throws and the catch runs. The literal stays AT the registerAutoloading call site rather than becoming a signature default, so it remains visible to a reader and to hydra gate-64, which reads that call's arguments. The new test asserts something real rather than merely not throwing: a prelude whose app cannot be resolved must leave spl_autoload_functions() untouched.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ❌ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-06 08:51 UTC
Download the full PDF report from the workflow artifacts.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ❌ | ||||
| Newman | ❌ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-08 10:19 UTC
Download the full PDF report from the workflow artifacts.
|
Reviewed as part of owning this repo's open-PR queue. Not merged, and the reason is one job. Strict-subset evaluation
This PR, on a fresh run after One name outside the base set, so this is not a strict subset and I am not landing it. (The earlier run you may have seen was from 2026-08-06 against a base of What actually fails — PHPUnit passes; the coverage ratchet does notJob 93089838477 step conclusions: 4 statements added, 2 covered. The two that are not are structural, not an oversight:
So the suite covers "never throws" and "is idempotent" but never once observes the prelude actually registering a prefix — which is the entire behaviour the PR exists to add. Two things worth fixing while you are here
$before = count(spl_autoload_functions());
OpenRegisterAutoloader::register();
$this->assertGreaterThan(expected: 0, actual: $before, ...);
The happy path is reachable in the PHPUnit environment. These tests already run inside a real Nextcloud (the job checks out the server, installs it, and enables the app before Note for the other five repos in this sweepThe coverage drop is a property of the change shape (a small, deliberately-untestable call site plus a happy path unreachable from unit tests), so the same ratchet failure will appear in every repo where this landed with the same test file. Worth fixing once in the shared test rather than six times. Nothing here argues against the change itself — the load-order analysis is correct and the defect is real. It just needs the happy path covered before it can pass this repo's ratchet. |
|
Addendum — I was pointed at launchpad#58, which was closed as a no-op that also regresses psalm, and asked to verify before merging. I measured it here rather than inheriting either conclusion. larpingapp is not launchpad. The premise holds here. 1. There IS a
|
| arm | tree | result |
|---|---|---|
| A | unmodified | apphost-autoload-prelude: OK, exit 0 |
| B | one class_exists('OCA\OpenRegister\AppHost\Bootstrap') injected |
FAIL larpingapp: ... references OCA\OpenRegister\AppHost\Bootstrap, but nothing under lib/AppInfo/ registers OpenRegister's autoloader first, exit 1 |
| A′ | restored | exit 0 |
So this repo is a genuine instance of the ADR-040 load-order defect in its Event-listener form, which gate-64 does not cover.
4. The psalm suppression
psalm.xml adds one <referencedClass name="OC_App"/> to an existing <UndefinedClass><errorLevel type="suppress"> list that already carries OCP\AppFramework\App, IBootstrap and others. It suppresses a stub-availability artifact — OC_App is server-private and absent from nextcloud/ocp — not a real finding, and it is scoped to one class rather than blanket. Consistent with the file's established pattern. PHP Quality (psalm) is SUCCESS on the fresh run.
Worth saying out loud: reaching for \OC_App is a real smell, but there is no OCP API for registering another app's autoloader, so the alternative is not available.
Verdict
The change is correct and wanted for this repo. It is still not mergeable for the reason in my previous comment — the coverage ratchet, and the fact that no test ever observes the prelude actually registering a prefix. Cover the happy path (these tests already run inside a real installed Nextcloud, so \OCP\Server::get(IAppManager::class) resolves) and this lands.
Whatever is decided for launchpad should not be generalised to here.
The coverage ratchet was the only thing red on this PR:
Coverage current: 71.67% (1022/1426 statements)
Coverage merge base: 71.73% (1020/1422 statements)
FAIL: coverage dropped by 0.06% against the merge base.
Four new statements, two of them uncovered — `OC_App::registerAutoloading()`
inside the prelude's try (unreachable in a bare unit run, where
`OCP\\Server::get()` throws first) and the `OpenRegisterAutoloader::register()`
call at the top of `Application::register()`, which had no test at all.
Rather than move the floor, this covers the thing the whole PR is about.
`OpenRegisterAutoloaderTest` proves the prelude never throws; it cannot prove
what actually matters — that once the prelude has done its job, the listeners
are there. Two of the three carry the server-authoritative skill-requirement
and XP-budget enforcement on character writes, and an unregistered listener is
indistinguishable from no enforcement at all.
`ApplicationRegisterTest` calls `register()` with a recording
`IRegistrationContext` and asserts the exact (event, listener) pairs, in both
worlds: OpenRegister resolvable (the post-prelude world, simulated by
tests/unit/AppInfo/fixtures/openregister-events.php declaring the three event
names — a name is the entire contract, since register() only ever passes the
class name and never constructs one) and OpenRegister absent (must attach
nothing and must not throw).
`Application` is built with `newInstanceWithoutConstructor()`: `App::__construct()`
needs the Nextcloud DI container, and `register()` neither reads nor writes
instance state.
Can-fail verified by deleting the ObjectUpdatingEvent registration:
1) ...ApplicationRegisterTest::testEveryListenerRegistersWhenOpenRegisterIsResolvable
Application did not register CharacterRequirementListener.
FAILURES! Tests: 2, Assertions: 7, Failures: 1.
and restored — `git diff lib/` empty. The fixture-loaded assertion guards the
other direction: if the fixture stopped making the names resolvable, the test
says so instead of passing over a register() that attaches nothing.
Quality Report — ConductionNL/larpingapp @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-specs | ✅ | ||||
| test-l10n | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 537/537 | |||
| PHPUnit | ✅ | ||||
| Newman | ❌ | ||||
| Playwright | ✅ | ||||
| Hydra gates | ✅ |
Quality workflow — 2026-08-09 15:59 UTC
Download the full PDF report from the workflow artifacts.
The defect
larpingappsorts beforeopenregister, and Nextcloud registers apps in sorted order:OC_App::getEnabledApps()doessort($apps)andCoordinator::registerApps()walks that list callingOC_App::registerAutoloading($appId, $path)and then$app->register()one app at a time. SoOCA\OpenRegister\is not autoloadable inside LarpingApp's ownregister()— on a perfectly healthy instance, with OpenRegister enabled.All three
class_exists('OCA\OpenRegister\Event\…')probes inregister()were therefore answeringfalse. Not "not loaded yet" —false, indistinguishable from OpenRegister being absent. LarpingApp registered no event listeners at all:DeepLinkRegistrationEventDeepLinkRegistrationListener— unified-search deep linksObjectCreatingEventCharacterRequirementListenerObjectUpdatingEventCharacterRequirementListenerThe last two carry the server-authoritative skill-requirement and XP-budget enforcement on character writes. That enforcement is server-side precisely because the client cannot be trusted, and it was silently not running. The app stayed enabled, served normally, and nothing in the UI reported the gap. A validation that is never invoked is indistinguishable from having no validation.
This is measured, not inferred from theory. The identical load order was observed directly on the sibling app
openbuild(also sorting beforeopenregister), whose CI logsOpenRegister AppHost\Bootstrap is not autoloadableon everyocccall while OpenRegister is installed and enabled the whole time.It is invisible on a dev instance: any alphabetically-earlier app that pulls OpenRegister's autoloader in (
doriathdoes) registers the prefix process-wide and masks the failure for every app that registers after it — exactly where you would test for it.The fix
lib/AppInfo/OpenRegisterAutoloader.php— put OpenRegister's PSR-4 prefix on the autoloader before the guards run:OC_App::registerAutoloading()touches only the autoloader and is idempotent.IAppManager::loadApp('openregister')is deliberately not used: it marks OpenRegister loaded and callsCoordinator::bootApp(), booting it before its ownregister()has run.falserather than throwing when OpenRegister genuinely is absent, so the existing guards still do their job.It lives in its own class so its "never throws" contract is reachable from a unit test:
Applicationcannot be constructed without a Nextcloud DI container.Relationship to gate-64
Same defect class as hydra gate-64
apphost-autoload-prelude(ADR-040), one namespace over. gate-64'sclass_existsrule only matchesOCA\OpenRegister\AppHost\…names, so it does not flag this file — larpingapp reports PASS today while being live-exposed. Worth widening the gate's rule 2 to anyOCA\OpenRegister\…literal.Verification
tests/unit/AppInfo/OpenRegisterAutoloaderTest.phpasserts the never-throws and idempotence contracts.phpcsclean on all touched files.Spec: new requirement in
openspec/specs/skill-requirement-enforcement/spec.md.