Skip to content

fix(quality): clear the psalm and phpstan gates and delete the phpstan baseline - #263

Merged
rubenvdlinde merged 1 commit into
developmentfrom
chore/strict-gates
Aug 4, 2026
Merged

fix(quality): clear the psalm and phpstan gates and delete the phpstan baseline#263
rubenvdlinde merged 1 commit into
developmentfrom
chore/strict-gates

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What

Drives two of scholiq's four red composer check:strict gates to zero without a baseline and without adding any suppression — in fact two existing suppressions are removed.

tool before after
phpcs 0 0
psalm 16 0
phpstan 15 0
phpunit exit 1 887 pass
phpmd 217 217 (unchanged — follow-up PR)

Root cause

22 of the 31 psalm/phpstan findings came from one cause: scholiq's engine-backed classes extend OpenRegister AppHost base classes that are absent from the analysis path. Repair\InitializeActions, Sections\SettingsSection and Settings\AdminSettings all read as "extends unknown class" — which PHPStan explicitly refuses to let ignoreErrors suppress — and every #[AuthorizedAdminSetting(AdminSettings::class)] attribute additionally failed its class-string<IDelegatedSettings> check.

Fixed by adding an analysis-only stub (the tests/stubs pattern already shipping in doriath, whose psalm/phpstan gates are green for exactly this reason) declaring GenericSettingsSection, GenericAdminSettings, GenericInitializeActions and Bootstrap with signatures mirroring the real openregister classes, and by registering the already-present TenantKeyService, TalkLinkService and GenericActionAuthService stubs in psalm.xml <stubs>.

That gives Psalm real type information instead of suppressing the symptom — which is why the UndefinedMethod suppression block for ActionAuthService::requireAction/getMatrix/setMatrix could be deleted.

Suppressions removed, not added

  • phpstan-baseline.neon deleted along with its includes: entry. Its 5 tracked-debt findings are fixed, not re-muted.
  • psalm.xml UndefinedMethod block for ActionAuthService deleted.
  • No --generate-baseline, no --set-baseline, no treatPhpDocTypesAsCertain: false, no new ignoreErrors patterns, no excludePaths widening.

Dead code removed rather than baselined

  • 4 redundant instanceof DOMElement === false guards both analysers agree can never be true (getElementsByTagName() only ever yields element nodes)
  • a redundant is_string() guard on end() of a non-empty explode() result
  • a redundant ?? [] on a non-nullable getContext()
  • an unused $tenantId parameter on resolveProgrammeIds()
  • an unused return value on createLessonForMaterial()

QtiImportService gains a firstElement() helper so the DOMNode -> DOMElement narrowing lives behind a declared return type. This satisfies both analysers without an inline annotation — which the house style forbids anyway (Squiz.Commenting rejects inline doc blocks, and there are zero if (! in lib/).

test:all

test:unit / test:all now pass --no-coverage, matching doriath. Coverage keeps its own test:coverage script. Without this the suite exits 1 on any runner with no xdebug/pcov driver even though all 887 tests pass — a failure that has nothing to do with test health.

Verification

PHP 8.3 container, fresh composer install. Positive control run before declaring green: an injected $node->thisMethodDoesNotExistPositiveControl() produced psalm exit 2 and phpstan exit 1 both naming the injected line; an injected $x=1; plus a lowercase comment produced phpcs exit 2 naming lines 443-444. Reverting restored exit 0 on all three, verified by git status --porcelain being empty.

…n baseline

phpstan: 15 errors -> 0. psalm: 16 errors -> 0. Both without a baseline and
without widening any suppression — `phpstan-baseline.neon` is deleted and its
`includes:` entry removed, and the psalm `UndefinedMethod` suppression block for
ActionAuthService is deleted too.

Root cause of 22 of the 31 findings: Scholiq's engine-backed classes extend
OpenRegister AppHost base classes that are absent from the analysis path, so
Repair\InitializeActions, Sections\SettingsSection and Settings\AdminSettings all
read as "extends unknown class" — which PHPStan explicitly refuses to let
ignoreErrors suppress — and every #[AuthorizedAdminSetting(AdminSettings::class)]
attribute additionally failed its class-string<IDelegatedSettings> check.

Fixed by adding an analysis-only stub (tests/stubs pattern already shipping in
doriath) declaring GenericSettingsSection, GenericAdminSettings,
GenericInitializeActions and Bootstrap with signatures mirroring the real
openregister classes, and by registering the existing TenantKeyService,
TalkLinkService and GenericActionAuthService stubs in psalm.xml <stubs>. That
gives Psalm real type information instead of suppressing the symptom, which is
why the ActionAuthService UndefinedMethod block could be removed.

Remaining findings were dead code, now removed rather than baselined:
- 4 redundant `instanceof DOMElement === false` guards that both analysers agree
  can never be true (getElementsByTagName only yields element nodes)
- a redundant `is_string()` guard on end() of a non-empty explode() result
- a redundant `?? []` on a non-nullable getContext()
- an unused $tenantId parameter on resolveProgrammeIds()
- an unused return value on createLessonForMaterial()

QtiImportService gains a firstElement() helper so the DOMNode -> DOMElement
narrowing lives behind a declared return type; this satisfies both analysers
without an inline annotation, which the house style forbids anyway.

test:unit/test:all now pass --no-coverage, matching doriath. Coverage still has
its own test:coverage script; without this the suite exits 1 on any runner with
no xdebug/pcov driver even though all 887 tests pass.

phpcs 0, psalm 0, phpstan 0, 887 tests green.
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/scholiq @ 716676b

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
composer ✅ 100/100
npm ✅ 750/750
PHPUnit ⏭️
Newman ⏭️
Playwright ⏭️

Quality workflow — 2026-08-03 22:42 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit f617a27 into development Aug 4, 2026
30 of 32 checks passed
rubenvdlinde added a commit that referenced this pull request Aug 4, 2026
…ot cause, three production bugs (#264)

* fix(tests): make the OpenRegister stubs a faithful mirror, not a fiction

scholiq's PHPUnit legs had never run. The job was gated behind the
static-analysis jobs, so on `development` it sat `skipped` with an
unexpanded matrix name — literally

    quality / PHPUnit (PHP ${{ matrix.php-version }}, NC ${{ matrix.nextcloud-ref }})

which reads like a configured-but-idle leg rather than a gate that has
never once produced a verdict. #263 cleared psalm and phpstan, the leg
expanded into two real jobs, and both went red: 887 tests, 231 errors,
12 failures.

That is a discovery, not a regression. The root cause is a single one:

`OCA\OpenRegister\*` resolves to two different implementations depending
on where the suite runs. Standalone it resolves to `tests/Stubs/` via the
PSR-4 mapping in tests/bootstrap.php. In CI the app is checked out into a
real Nextcloud server tree next to openregister@development, the app is
enabled, and Nextcloud's autoloader wins — so every `createMock()` is
built from the REAL class.

The stubs did not match the real class. `ObjectService::find()` was
declared `find(string $id, string $register, string $schema): mixed`;
the real one is `find($id, $_extend, $files, $register, $schema, ...)
: ?ObjectEntity`. `saveObject()` was declared
`saveObject(string $register, string $schema, array $object)`; the real
one takes the payload FIRST. `ObjectEntity` declared `getRegister()`,
`getSchema()` and `getUuid()` as concrete methods; on the real entity
those are `__call` magic from `OCP\AppFramework\Db\Entity`, which PHPUnit
cannot configure on a mock.

The stub's own docblock asserted that OpenRegister "accepts two call
signatures". It does not, and never did — the prose was written to match
the stub, and the phpstan baseline hid the contradiction.

So 887 tests passed standalone against an API that does not exist, and
231 of them errored the moment the real class was on the autoloader.

This commit fixes the harness rather than the symptom:

- tests/Stubs/Service/ObjectService.php and tests/Stubs/Db/ObjectEntity.php
  are now signature-for-signature mirrors of the real classes, including
  the `@method` block that makes the magic accessors visible to PHPStan.
  ObjectEntity extends the same OCP base class, so it has the same
  magic-accessor behaviour, and is concrete so tests can build instances.
- tests/Stubs/Db/{Register,Schema}.php added so the mirrored signatures
  can name the same union types the real ones name.
- tests/Support/OrEntityFactory builds real ObjectEntity instances, which
  is the only way to give a test an entity with a register/schema/payload
  now that the magic getters correctly cannot be mocked. Required from the
  bootstraps rather than composer autoload-dev, because a dev-built vendor/
  bakes autoload-dev into the runtime classmap and can shadow real app
  classes instance-wide (openregister#2036).
- tests/Unit/Contract/OpenRegisterContractTest asserts the contract against
  whichever class actually resolved. It passes against the stub mirror AND
  against openregister@development, so the next drift is one named red test
  instead of a silently dead suite. Positive control: declaring
  getRegister() concretely on the mirror turns it red naming that method.

Two production defects fall out of the corrected stub, both of which
would have fatalled against the real service:

- CoursePackageImportService called saveObject() positionally at 12 sites
  and QtiImportService at one, passing the register slug where the payload
  belongs. Now named arguments.
- QtiImportService then null-checked and is_array()-checked a return value
  that is a non-nullable ObjectEntity.

Correcting the stub also turns PHPStan into a real detector for this
class of drift: it went from 0 errors to 108, of which these 29 were the
call-shape bugs. The phpstan gate had been green because it was resolving
lib/ against a stub that lib/ was written to match — circular, and green
for the same reason the suite was.

Harness parity, measured: before, standalone reported 0 errors while CI
reported 231. After, standalone reports 227 and a local run against
openregister@development reports 229. The suite now fails in the same
place it fails on the runners.

* fix(tests): repair the 231 PHPUnit errors and the three production bugs behind them

Second half of the scholiq PHPUnit repair. The first commit made the
OpenRegister stubs a faithful mirror; this one fixes the 59 test files
that had been written against the fiction, and the production defects
that fixing the stubs made visible.

Result: 887 tests / 231 errors / 12 failures  ->  895 tests / 0 / 0.
Standalone and a local run against openregister@development now report
byte-identical results, so the suite fails in the same place it fails on
the runners.

Three production defects, all of which the old stubs hid:

1. `method_exists()` cannot see a `__call` accessor.
   ObjectEntity's getUuid()/getRegister()/getSchema() come from
   OCP\AppFramework\Db\Entity::__call, so `method_exists($entity,
   'getSchema')` is FALSE on a real entity — measured against
   openregister@development, not assumed.

   `ListenerSchemaResolver::schemaSlug()` and `registerSlug()` and
   `isOwnRegister()` all guarded on exactly that. They returned '' /
   false for every genuine ObjectEntity, and as their own docblock says,
   "every caller treats '' as 'not my object' and returns early" — so
   every scholiq OpenRegister listener silently did nothing in
   production. It failed closed and silently, which is why nothing ever
   reported it.

   `CoursePackageImportService::extractUuid()` had the same probe, so it
   returned null for every save: createCourse() produced no course id and
   the QTI item-bank import was skipped entirely.

   The old tests/Stubs entity declared those accessors concretely, so
   `method_exists()` was true there and only there. The unit suite could
   not have caught this.

   Fixed to `is_callable()`, which is true for a magic accessor.
   jsonSerialize() IS declared, so `method_exists()` remains correct for
   that one — the sweep distinguishes the two.

2. Two more stub fictions, found by fixing the first two.
   `TransitionEngine::transition()` was declared `: void`; the real one
   returns ObjectEntity. Tests whose transition callback returned nothing
   were green standalone and threw in CI — and RejectionMappingHandler
   and SupportRequestSubmitHandler wrap the call in `catch (\Throwable)`,
   so their tests passed in CI while swallowing that TypeError. Green for
   the wrong reason is worse than red.

   `ObjectCreatedEvent` declared getRegister()/getSchema(); the real
   event carries only getObject(). Its sibling ObjectTransitionedEvent
   genuinely does expose them — the two differ, which is precisely why
   the surface has to be mirrored rather than assumed.

3. PHPStan was measuring nothing for this API. `scanDirectories`
   includes tests/Stubs, so it checked lib/ against a stub lib/ had been
   written to match. With the mirror corrected it went 0 -> 108 errors:
   29 were the positional saveObject() call shapes (fixed in the previous
   commit) and 63 were branches handling an array or a null that the real
   return types make unreachable. Those branches are now removed, so the
   gate measures the real contract. PHPStan and Psalm are both clean.

OpenRegisterContractTest is extended to cover all of the above —
signatures, the event-surface difference, and an explicit assertion that
`method_exists()` does NOT see the magic accessors while `is_callable()`
does. Positive control re-run: declaring getRegister() concretely on the
mirror turns it red naming that method; reverting restores green.

Test-side changes are re-plumbing, not weakening: no test was skipped,
deleted or had an assertion loosened. Four expectations were changed on
purpose, each because they encoded a state the real API cannot reach:

- RejectionResubmitGuardTest and SupportRequestSubmitHandlerTest
  simulated a save failure by returning null from saveObject(), which is
  non-nullable. Both now exercise the reachable failure — a saved entity
  with no usable id — which is what the guards actually check.
- ExemptionGrantHandlerTest's helper dropped its `null` branch for the
  same reason.
- SessionConflictListenerTest compared against a bare payload array;
  real jsonSerialize() returns the payload plus an `@self` block, so
  exact equality could never hold. Now asserts the same intent via a
  callback.

One genuine flake fixed: AssessmentDrawResolverTest's shuffle test used
15 trials over a two-choice fixture, so P(false green) is 2*(1/2)^15 —
measured at 7 in 200,000 simulated runs, and it fired once during
verification. Raised to 40 trials, which is strictly harder (every added
trial must also satisfy the per-trial invariant), not looser.

Known remaining, deliberately not fixed here: RejectionResubmitGuard has
no try/catch around createResubmissionJob(), so a real save failure now
propagates out of check() rather than blocking the transition. That is a
behaviour question, not a test-harness one.

* fix(import): parse XML with loadXML(), not DOMDocument::load()

Third and last root cause behind the red PHPUnit legs — and the one with
the widest production blast radius.

Nextcloud's `OC::init()` installs, in server `lib/base.php`:

    libxml_set_external_entity_loader(static fn () => null);

to block XXE. libxml routes the PRIMARY document of
`DOMDocument::load($path)` through that same loader, so with it installed
`load()` always returns false. Measured, not assumed:

    baseline (no entity loader)     load()=true   loadXML(file_get_contents)=true
    after NC-style entity loader    load()=false  loadXML(file_get_contents)=true

Every scholiq XML parser used `load($path)` — 8 call sites across
CommonCartridgeParser, MoodleBackupParser, MoodleQuizQuestionMapper,
QtiImportService and CoursePackageImportService. Inside a real Nextcloud
they all fail, which means Common Cartridge import, Moodle backup import,
QTI import and course-package import have never worked on an actual
instance. The unit suite could not see it because it ran without
Nextcloud bootstrapped.

This accounted for the last 10 red tests: 4 errors reporting
"Could not parse imsmanifest.xml as XML" plus 6 downstream failures in
the importers that consume those parsers. All 10 were already failing on
`development` with the identical message — they are pre-existing, and
they only became visible when the PHPUnit job first ran.

All 8 sites now read the file and call `loadXML()`, which takes a string
and is unaffected by the entity loader while still resolving no external
entities — so the XXE hardening is preserved, not worked around.

tests/Unit/Contract/NextcloudXmlLoaderContractTest installs the same
loader and asserts both parsers still work, plus the premise itself (that
`load($path)` fails and `loadXML()` does not) so the workaround can be
retired if Nextcloud or libxml ever changes. Positive control: reverting
CommonCartridgeParser to `load($path)` reproduces the exact CI error
locally — `RuntimeException: Could not parse imsmanifest.xml ... as XML`
— and restoring it (verified byte-identical) restores green.

The local reproduction harness now installs the entity loader too, so it
models both halves of the CI environment: the real OpenRegister classes
AND Nextcloud's libxml hardening.

Suite: 898 tests, 4121 assertions, 0 errors, 0 failures — byte-identical
between the standalone and openregister@development harnesses.
PHPStan 0, Psalm clean, PHPCS 0 errors.

* test: TEMPORARY positive-control probe — proves the PHPUnit leg can fail

Deliberately broken assertion in LeaderboardControllerTest. A green suite
is only evidence about the suite once you have shown it can go red, and
this leg had never produced a verdict at all before this branch. Reverted
in the next commit.

* test: revert the positive-control probe

The probe did its job: both PHPUnit legs went red naming the test —

  1) OCA\Scholiq\Tests\Unit\Controller\LeaderboardControllerTest::testNoLeaderboardRowRefused
     CI positive control probe
     Failed asserting that 404 is identical to 418.

so the green on this branch is a measured green, not an absent check.
Tree is byte-identical to dc2ab9c (verified with git diff), which is the
commit whose legs reported 898 tests / 4121 assertions / OK.
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.

2 participants