fix: close the ADR-084 contract drift behind 19 PHPUnit failures and 44 static findings - #535
Merged
Merged
Conversation
…44 static findings PHPUnit was red in all six matrix cells with an IDENTICAL count (Tests 705, Errors 7, Failures 12, Warnings 3, Skipped 20), which looked like a class-load fatal. It was not: the suite ran to completion in every cell. The shared cause is that this app's consumption of OpenRegister drifted from the contract OpenRegister now publishes (OCA\OpenRegister\Contract\ObjectServiceInterface / ObjectEntityInterface), and both the production code and its doubles were pinned to the older surface. Measured, in-container on PHP 8.4 against the same openregister: PHPUnit 705 tests: 19 red -> 1 red -> 0 red phpstan 37 errors -> 0 psalm 4 errors -> 0 phpmd 3 findings -> 0 phpcs exit 0 before and after (warnings only, pre-existing) Six distinct defects, not one: 1. The saveObject() double omitted the contract's second parameter. ObjectServiceInterface::saveObject() is (object, extend, register, schema, uuid, ...). MergeOrganisatieServiceTest's willReturnCallback declared (object, register, schema, uuid). PHPUnit resolves the subject's NAMED arguments against the generated mock's own signature and then invokes the callback POSITIONALLY, so the capture silently recorded extend-as-register and register-as-schema. Nothing threw; every assertion that looked a save up by (schema, uuid) reported "no such save". Six failures. The callback now mirrors the contract position for position. 2. Three controller tests wired their fixture into a ContainerInterface double while the subject holds an INJECTED contract. The subject was left holding a different, unconfigured mock: reads returned empty, and the organisation guard refused a caller reading their OWN organisation. Five failures, one of them a cross-tenant test passing straight through the check it exists to prove. 3. getObjectService() asked the container for the CONCRETE class and gated on `instanceof ObjectService`. Anything that satisfies the published interface without being that exact class - i.e. every double a leaf app can build - fell to the fail-closed arm and refused an owner. Fixed in ContractApprovalService, ContractStatusService and SbomImportService: ask for the contract, narrow on the contract. 4. Two tests referenced RegisterMapper / MetadataHydrationHandler with no import, so they resolved inside the test's own namespace. Four errors. The listener's dependency on both is gone (see 6), so the imports went with it. 5. QueryLimitBoundingTest seeded only `container` and `logger` by reflection on a newInstanceWithoutConstructor() instance. Reading an uninitialised typed property is an Error, not a null, so the test died before observing the query it exists to observe. It now seeds `objectService` too. 6. UserProfileUpdatedEventListener reached past the contract into SchemaMapper, RegisterMapper and Service\Object\SaveObject\ MetadataHydrationHandler to regenerate `_name` before saving. That was redundant - ObjectService::saveObject() calls hydrateObjectMetadata() on both its create and its update path - and it is what psalm reported as two UndefinedClass errors and phpmd as a LongVariable plus an unused $registerEntity. All three dependencies removed. Production defects found on the way, each fixed at the call site: - ContactpersonenController passed `silent: true` TWICE in one saveObject() call (a merge artefact); psalm InvalidNamedArgument, phpstan duplicate. - GebruikSyncService passed `id:` where the contract's parameter is `uuid:`. The name was corrected, not dropped. - ContactpersoonService tested `findSilent(...) === null`. findSilent() declares a NON-nullable ObjectEntityInterface and lets the mapper's DoesNotExistException out, so the distinct "not found" entry was unreachable and every miss came back carrying an `error` key instead. Now caught explicitly. - ContactPersonHandler::findContactPersonByUsername() was private, had no caller, and called findAll($filters, $registerId, $schemaId) POSITIONALLY against findAll(array $config, bool $_rbac, bool $_multitenancy) - the register id would have landed in $_rbac and the search run unscoped. Deleted with the reasoning recorded in place. - OrganizationHandler had one saveObject() with no register/schema at all, leaving the write to whatever scope the service happened to carry. It now falls back to the entity's own coordinates. Six call sites pushed a payload into the entity with setObject() and read it straight back out. setObject(), setOrganisation() and getId() are implementation-only accessors reached through Entity::__call() and are not on ObjectEntityInterface; the payload is now threaded through explicitly. saveObject() is PUT-semantic, so every unchanged field is still carried forward. Two constructors dropped an unused ContainerInterface (phpstan: "never read, only written") - ADR-084 replaced the lazy lookup with the injected contract. lib/AppInfo/Application.php's hand-written factories updated to match; tests/Unit/AppInfo/CompositionRootArgumentsTest.php covers that. tests/Stubs/Db/ObjectEntity.php's header documented the OPPOSITE of the current truth. It said getOrganisation() is magic on the real entity, so declaring it here inverts method_exists(). ADR-084 changed that: the real ObjectEntity implements ObjectEntityInterface, and an interface method cannot be served by __call(), so the real class declares all six concretely. The stub mirrors it, keeps the backing `organisation` property (that is what Entity::getter() and readOwningOrganisation() key on), and the header now says so. testTheMagicEntityDoubleMatchesTheRealObjectEntity AccessorShape was asserting the pre-ADR-084 shape and is re-pointed at the current one, pinning BOTH halves so the softwarecatalog#490 data-loss path cannot come back. No named argument was removed anywhere in this change. `id:` -> `uuid:` is a NAME correction to match the published signature. E2E Tests, Hydra Gates and Quality Report are NOT addressed here and remain red; they were not diagnosed.
Contributor
Quality Report — ConductionNL/softwarecatalog @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| check-manifest | ✅ | ||||
| check-vue-demi | ✅ | ||||
| test-l10n | ✅ | ||||
| format | ✅ | ||||
| composer | ✅ | ✅ 130/130 | |||
| npm | ✅ | ✅ 704/704 | |||
| app:check-code | ⏭️ | ||||
| info.xml | ✅ | ||||
| REUSE | ❌ | ||||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ❌ | ||||
| Hydra gates | ❌ |
Quality workflow — 2026-08-16 20:48 UTC
Download the full PDF report from the workflow artifacts.
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.
What this closes
softwarecatalogwas the fleet's most-red app: 12 failing jobs ondevelopment(run 31964022723, sha53809931). This PR closes PHPUnit (all 6 cells) + phpmd + phpstan + psalm. It does not touch E2E, Hydra Gates or Quality Report — those were not diagnosed and stay red.Measured, not inferred
Reproduced locally before any edit, and re-measured after. PHPUnit ran in-container on PHP 8.4 against a real
openregister(sqdev-nc), which reproduced CI's counts exactly —Tests: 705, Assertions: 2812, Errors: 7, Failures: 12, Warnings: 3, Skipped: 20, byte-identical to the CI cell. Static analysis was run through the project's own composer scripts (composer phpmd/psalm/phpstan/phpcs), never a hand-rolled invocation —phpmd.xmlextends ahydra-gatesruleset and reports different findings without it.composer phpstancomposer psalmcomposer phpmd(exit 0)composer phpcs— warnings only, pre-existing, unchangedRoot cause
All six PHPUnit cells failed with an identical count, which looks like a class-load fatal. It is not — the suite ran to completion in every cell. The shared cause is ADR-084 contract drift: this app's use of OpenRegister diverged from the contract OpenRegister now publishes (
Contract\ObjectServiceInterface/Contract\ObjectEntityInterface), and both the production code and its test doubles were pinned to the older surface.The six distinct defects
1. The
saveObject()double omitted the contract's second parameter. The contract is(object, extend, register, schema, uuid, …).MergeOrganisatieServiceTest'swillReturnCallbackdeclared(object, register, schema, uuid). PHPUnit resolves the subject's named arguments against the generated mock's own signature and then invokes the callback positionally — so the capture silently recordedextend-as-registerandregister-as-schema. Nothing threw; every assertion looking a save up by(schema, uuid)reported "no such save". 6 failures.2. Three controller tests wired the fixture into a
ContainerInterfacedouble while the subject holds an injected contract. The subject was left holding a different, unconfigured mock: reads came back empty, and the organisation guard refused a caller reading their own organisation. 5 failures — one of them a cross-tenant test passing straight through the check it exists to prove.3.
getObjectService()asked for the CONCRETE class and gated oninstanceof ObjectService. Anything satisfying the published interface without being that exact class — i.e. every double a leaf app can build — fell to the fail-closed arm and refused an owner. Fixed inContractApprovalService,ContractStatusService,SbomImportService.4. Two tests referenced
RegisterMapper/MetadataHydrationHandlerwith no import, resolving inside the test's own namespace. 4 errors.5.
QueryLimitBoundingTestseeded onlycontainer+loggerby reflection on anewInstanceWithoutConstructor()instance. Reading an uninitialised typed property is anError, not a null, so the test died before observing the query it exists to observe.6.
UserProfileUpdatedEventListenerreached past the contract intoSchemaMapper,RegisterMapperandService\Object\SaveObject\MetadataHydrationHandlerto regenerate_namebefore saving. Redundant —ObjectService::saveObject()callshydrateObjectMetadata()on both its create and update path (SaveObject.php:3692/:3873). This was psalm's twoUndefinedClasserrors and phpmd'sLongVariable+ unused$registerEntity. All three dependencies removed.Production defects found on the way
ContactpersonenControllerpassedsilent: trueTWICE in onesaveObject()call — a merge artefact from "restore two parameters my dangling-reference pass shadowed".GebruikSyncServicepassedid:where the contract's parameter isuuid:. The name was corrected, not dropped.ContactpersoonServicetestedfindSilent(...) === null.findSilent()declares a non-nullableObjectEntityInterfaceand letsDoesNotExistExceptionout, so the distinct "not found" entry was unreachable and every miss came back carrying anerrorkey. Now caught explicitly.ContactPersonHandler::findContactPersonByUsername()— private, no caller, and calledfindAll($filters, $registerId, $schemaId)positionally againstfindAll(array $config, bool $_rbac, bool $_multitenancy). The register id would have landed in$_rbacand the search run unscoped. Deleted, reasoning recorded in place.OrganizationHandlerhad onesaveObject()with no register/schema at all, leaving the write to whatever scope the service happened to carry. Now falls back to the entity's own coordinates.Named arguments: none removed
Rule 7a is gate-enforced, so explicitly: no named argument was stripped anywhere in this change. The only name that changed is
id:→uuid:inGebruikSyncService, which corrects the name to the published signature. Where arguments disappear from a call it is because the parameter itself was deleted (unusedContainerInterfaceon two constructors; three dead parameters on one private helper). ThesaveObject()double was repaired by adding the contract's?array $extend = []— mirroring the real surface, which is the prescribed repair. Verified by diffing every removedname:line.The stub documented the opposite of the truth
tests/Stubs/Db/ObjectEntity.php's header saidgetOrganisation()is magic on the real entity, so declaring it invertsmethod_exists(). ADR-084 changed that: the realObjectEntity implements ObjectEntityInterface, and an interface method cannot be served by__call(), so the real class declares all six concretely (openregister lib/Db/ObjectEntity.php:833). The stub now mirrors it, keeps the backingorganisationproperty (whatEntity::getter()andreadOwningOrganisation()key on), and the header says so.testTheMagicEntityDoubleMatchesTheRealObjectEntityAccessorShapewas pinning the pre-ADR-084 shape; it is re-pointed at the current one and now pins both halves, so the softwarecatalog#490 data-loss path cannot come back.Six
setObject()call sitessetObject(),setOrganisation()andgetId()are implementation-only accessors reached throughEntity::__call()and are not onObjectEntityInterface. Six sites pushed a payload into the entity and read it straight back out. The payload is now threaded through explicitly.saveObject()is PUT-semantic, so every unchanged field is still carried forward.One deliberate suppression
OrganizationSyncService::__constructhas 11 parameters (phpmd threshold 10). Suppressed with the reasoning in place, matchingContactpersonenController:$containeris not dead weight there (it lazily resolvesIUserManager/IGroupManager), so removing it takes the count to twelve, and hiding a pair behind a parameter object hides a dependency the composition root must state anyway. The real remedy is splitting a 2,700-line service — a refactor, not a quality-gate change.Still red — not addressed
playwright-reportartifact'sdata/*.md, which I did not open.18fe6f9→f935e2c); the base must be re-run against the same gate-package sha before its numbers mean anything.Verification note
The final full-suite PHPUnit run measured 1 remaining failure (
ContractApprovalServiceTest::testAuthorizeSubmitOwningAanbodBeheerderIsAuthorized), which thegetObjectService()contract fix then addressed. That fix, and the phpstan/psalm/phpmd work that followed it, were verified by the static tools but not re-verified against the full PHPUnit suite — the shared test server was taken out of service before I could re-run it. CI on this PR is the first full-suite measurement of the final tree.