Skip to content

refactor(deps): type-hint OpenRegister's published contract (ADR-084) - #519

Merged
rubenvdlinde merged 15 commits into
developmentfrom
refactor/adr-084-type-hint-the-contract
Aug 16, 2026
Merged

refactor(deps): type-hint OpenRegister's published contract (ADR-084)#519
rubenvdlinde merged 15 commits into
developmentfrom
refactor/adr-084-type-hint-the-contract

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What

Services take OCA\OpenRegister\Contract\ObjectServiceInterface instead of the
concrete class, bound in the composition root, with conduction/hydra-gates
bumped to v1.8.0 so composer installs the interface into vendor/.

Why this is what unblocks ADR-083

A leaf app cannot load a class from another Nextcloud app, so a typed
constructor parameter had no satisfiable double:

TypeError: __construct(): Argument #N ($objectService) must be of type
OCA\OpenRegister\Service\ObjectService, class@anonymous given

All seven ADR-083 rollout PRs stopped there. Measured across the fleet, ten of
sixteen consumers had hand-rolled a double
rather than solve it — 0–13 methods
each against a real class of 88.

ObjectServiceInterface is 25 methods, scoped by measuring per CLASS rather than
per call: a class can only type-hint the interface if every method it calls is
on it. Per call, eight methods look sufficient; per class they cover 58% and
strand 414 consumer classes.

What is deliberately NOT touched

Files naming 'OCA\OpenRegister\Service\ObjectService' only as a container
key
. Those are availability-guarded lookups — the ADR-083 rule-1 exception —
and the string must go on naming the concrete service, because that is the key
the alias resolves to. Rewriting it would make the container look up something
nobody registered.

The binding

registerServiceAlias, not a factory: it resolves when something asks for the
interface, so an instance without OpenRegister fails at the route that needed the
data rather than at registration. Both names are strings and neither triggers an
autoload — which is what keeps ADR-083 rule 3's promise that the start screen
still boots.

On the transformer

The first version masked string literals with a regex, and it silently
under-converted: in one file the comment // King's Day. opened a string that
did not close until date('N') 240 lines later, so every type position between
them looked quoted and the file was skipped. A skipped file is
indistinguishable from a clean one
— it took comparing counts to notice.

It now takes comment and string ranges from PHP's own token_get_all(), lints
every file with php -l and reverts on failure, and reports every file it
declines to convert together with the reason.

Related

🤖 Generated with Claude Code

5 file(s) reached OpenRegister through $this->container->get(...) on an
UNCONDITIONAL path — no availability check, no degrading catch. The
dependency was announced nowhere: not in the constructor, not in the use
block, not in any type. It appeared mid-method, as a string.

Now constructor-injected and typed, so the dependency is visible to a
reader and to tooling. Behaviour is unchanged: the same object, from the
same container, resolved at construction instead of at first use.

ContainerInterface is dropped only where nothing else used it.

Deliberately NOT converted, because they are correct as written (ADR-083
rule 1's exception): lookups behind isInstalled()/getInstalledApps(), and
lookups whose catch degrades rather than rethrows.

Verified per file: php -l clean, and gate-66's lookup check reports zero
remaining findings for each file changed.

gate-66 for this app: 23 -> 8.
21 lib classes and 17 test classes take
OCA\OpenRegister\Contract\ObjectServiceInterface instead of the concrete class,
bound in the composition root, with hydra-gates bumped to v1.8.0 so composer
installs the interface into vendor/.

This is what makes the ADR-083 conversion mockable: a leaf app cannot load a
class from another Nextcloud app, so a typed constructor parameter had no
satisfiable double. Test doubles now mock the contract, which does load.

Files naming ObjectService only as a CONTAINER KEY are untouched — those are
availability-guarded lookups (the ADR-083 rule-1 exception), and the string must
go on naming the concrete service because that is the key the alias resolves TO.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/softwarecatalog @ 9c568d2

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-15 07:53 UTC

Download the full PDF report from the workflow artifacts.

The first pass through this app used a transformer with three defects, each of
which failed SILENTLY -- a skipped file is indistinguishable from a clean one.

1. It masked string literals with a REGEX. An apostrophe in a comment
   (`// King's Day.`) opened a string that did not close for 240 lines, so
   every type position between them looked quoted and the file was skipped.
   Now the comment and string ranges come from PHP's own token_get_all().

2. It compared PHP's BYTE offsets against Python's CHARACTER indices. One file
   differed by 94 -- em dashes in prose comments -- so spans after the first
   non-ASCII byte were misaligned and a docblock was judged "not in a comment".
   Everything is handled as bytes now.

3. It missed short-form RETURN types (`): ?ObjectService`), which neither the
   parameter nor the fully-qualified pattern matched.

And it dropped the concrete import even where `ObjectService::class` or
`instanceof ObjectService` still needed it. That one is not merely incomplete,
it is silent damage: `::class` does not require the class to exist, so the
lookup would have resolved to this app's own namespace.

An invariant check now enforces the rule -- a file may import the contract, or
both, but never the contract alone while still naming bare ObjectService -- and
reports 0 for every app in this rollout.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/softwarecatalog @ f7710f6

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-15 08:06 UTC

Download the full PDF report from the workflow artifacts.

ADR-083 added a constructor parameter; the test constructions still passed the
old argument count:

    ArgumentCountError: Too few arguments to __construct(),
    N passed and exactly N+1 expected

Each site gains one argument BY NAME, which fills the right slot whether the
preceding arguments were written positionally or by name — so the same edit
works for both shapes, and a call short by more than this one parameter still
errors, correctly.

Every touched file is re-parsed with php -l and reverted on failure, and a
re-scan reports 0 remaining sites in each app.
phpstan caught a defect in the rollout transformer:

    PHPDoc tag @var for property $objectService with type
    OCA\OpenRegister\Service\ObjectServiceInterface is not subtype of native
    type OCA\OpenRegister\Contract\ObjectServiceInterface

The docblock rewrite matched `@var \OCA\OpenRegister\Service\ObjectService` and
appended `Interface` to the CLASS name while leaving the NAMESPACE alone, so the
declared type named a class that does not exist. The native type next to it was
correct, which is why only phpstan noticed — PHP itself never reads the docblock,
and the tests pass either way.

That is the fifth silent failure from this transformer, and the same shape as
the others: it produced plausible output that no runtime check disagreed with.
…e-fix one

These branches were cut from ADR-083 branches predating the 2026-08-14
correction, so they carry an older code-quality.yml whose push trigger has no
refactor/** — which is this branch's prefix. development's version says why that
matters:

    An ALLOW-LIST of branch prefixes is a gate with a hole in it, and the hole
    is SILENT: a branch matching nothing gets no CI at all, and its last visible
    status is whatever it inherited — indistinguishable, on every dashboard,
    from a branch that passed.

Observed here: softwarecatalog#519 settled at FOUR checks (CodeQL and Analyze
only) and read as green, having previously reported 43. shillinq#556 did the
same at three, which is shillinq#557.

Takes merge-hygiene.yml with it, the companion added in the same change, which
runs the fast structural checks on ** so an unlisted prefix is not completely
unguarded.

This restores coverage via the PUSH path. It does not explain why the
pull_request runs stopped, which is tracked separately.
…isfy the contract

Three distinct pre-existing defects, all of which only became visible once the
tests could actually construct their subjects.

1. ARITY, completely this time. The first pass added `objectService` only,
   because that is the parameter ADR-084 was about. ADR-083 added others in the
   same commit — softwarecatalog's ContactpersonenController gained THREE
   (objectService, magicMapper, organisationService) — so a call could be fixed
   for one and still be short by two. Every required parameter is now supplied,
   by NAME so it fills the right slot regardless of the existing arguments.

2. IMPORTS for the types those arguments name. `createMock(MagicMapper::class)`
   without a `use` resolves the short name against the TEST's own namespace, and
   `::class` does not require the class to exist — so it silently mocks a class
   nobody declared. Same trap as the container-key strings in lib/.

3. The ObjectEntity STUB now implements ObjectEntityInterface. Once
   ObjectServiceInterface is mocked its return types are enforced:

       Method find may not return value of type MockObject_ObjectEntity, its
       declared return type is "?OCA\OpenRegister\Contract\ObjectEntityInterface"

   A hand-rolled double that does not declare the interface cannot be handed
   back. This is ADR-084's argument arriving in the tests: ten apps had such a
   double, and none of them was checked against anything until now.

softwarecatalog also gains a MagicMapper stub, because ADR-083 injected
OpenRegister's MagicMapper into a controller and this app has no way to load it.
That stub is debt of exactly the kind ADR-084 removed for ObjectService — noted
in the file so it stays visible rather than becoming furniture.

php -l on every touched file, reverted on failure.
…Db layer

ADR-083 injected OCA\OpenRegister\Db\MagicMapper into three classes here. That
is another app's DATABASE layer — the coupling ADR-022 exists to prevent — and
no leaf app can load it, so its tests could not construct their own subjects. I
had added a hand-rolled MagicMapper stub to get past that; this removes the need
for one instead.

All three sites were doing the same thing: save without schema validation. The
published contract already exposes that, and saveObject() is not a lesser
route — OpenRegister's own SaveObject calls

    metaHydrationHandler->hydrateObjectMetadata(entity:, schema:)
    objectEntityMapper->update(entity:, register:, schema:)

which IS the magic-mapper route, with the metadata hydration these callers were
performing by hand. The comment claiming a plain save touches "just the blob
table" was wrong; the code was reimplementing OpenRegister's save pipeline one
layer too deep.

The flags matter, and one of them nearly went missing. FIX #434 chose
MagicMapper for TWO reasons, not one:

    to avoid validation errors on the organisatie field (stored as UUID string
    but Schema expects object type) AND to avoid triggering ObjectUpdatedEvent
    cascades. That could interfere with the ongoing org activation process.

`_validation: false` covers the first. The second needs `silent: true`, and a
replacement carrying only the validation flag would have re-emitted those events
into an in-flight org activation — a behaviour change with no test to catch it.
Both flags are now passed at every site.

Left alone: ContactpersoonService's two `container->get(MagicMapper)` lookups.
They are lazy, not injected, so they neither block tests nor need a stub; they
carry the same FIX #434 reasoning and are worth converting on their own terms.
The previous commit left these deliberately: they are lazy container lookups
rather than injected dependencies, so they blocked no test and needed no stub.
They carry the same FIX #434 reasoning, and converting them needed reading what
each actually does rather than pattern-matching the first one.

Site 1 changes only the payload (username), so it is the same shape as the three
already converted: _validation: false, silent: true.

Site 2 is different, and this is why they were worth separating. It sets two
pieces of entity METADATA that the payload API expresses differently:

  organisation  travels in `@self`. SaveObject reads it and applies it via
                setOrganisation() BEHIND AN ACCESS CHECK — so an organisation
                the caller may not use is now refused rather than written. The
                direct mapper call bypassed that check entirely, which is a
                quiet authorisation improvement, not just a refactor.
  owner         is not settable from the payload at all; SaveObject derives it
                from the acting user, so the UID is resolved to an IUser and
                passed as `currentUser`.

Both keep _validation: false and silent: true, so neither the organisatie type
mismatch nor the ObjectUpdatedEvent cascade behaviour changes.

lib/ now has no code reference to OCA\OpenRegister\Db\MagicMapper. What remains
is prose in RenameDutchCatalogColumns explaining how OpenRegister's schema sync
adds columns, which is documentation of the other app's behaviour and correct
where it is.
phpstan caught what the first pass missed.

  Access to an undefined property OrganizationSyncService::$magicMapper

Five more `$this->magicMapper->update()` calls remained in that file; the first
pass converted one. All five now save through the published contract, carrying
the organisation in `@self` where the code had set it on the entity — which
SaveObject applies behind an access check the direct mapper call skipped.

  Argument for parameter $silent has already been passed

My own "add silent: true" pass matched a second time inside one already-edited
block and produced a duplicate at broken indentation. Removed.

  TooFewArguments ... expecting objectService / organisationMapper to be passed

The composition root builds OrganizationSyncService and GebruikSyncService BY
HAND, so ADR-083's new constructor parameters never reached them. psalm found
this; PHPUnit could not, because the tests construct these services directly and
never go through Application.php. A registration that cannot build its service
fails at runtime on first use, which is the failure mode ADR-083 rule 3 is
about.

Both now resolve ObjectServiceInterface through the alias registered in the same
file, so the composition root consumes its own binding rather than naming the
concrete class again.
phpmd, on this branch:

    UndefinedVariable   $objectService

ADR-083 replaced

    $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService');

with an injected property and rewrote most usages to $this->objectService — but
not all. What remained read a local that no longer exists. In PHP that is not a
parse error and not a test failure unless the line executes; it is null at
runtime, and the call it feeds gets null instead of the service.

Rewritten only where the enclosing function has no assignment to that local AND
does not take it as a PARAMETER — several helpers legitimately receive it, e.g.
CreditLimitGuard::sumOutstandingCents(object $objectService, ...), and those are
untouched.

php -l on every touched file, reverted on failure.
Several classes here share a FILE NAME with another in a different namespace —
lib/Service/ChecklistService.php and lib/Service/Inspection/ChecklistService.php,
BelplanRoutingService, HearingService and others. The arity fixer keyed
constructors by that file name, so some constructions received arguments from
the wrong class:

    Error: Unknown named parameter $settingsService

Classes are now resolved through the file's own `use` imports, and the pass
refuses to act unless it is certain: a constructor that exists but parses to
nothing is treated as a PARSE FAILURE and skipped, never as "takes no
arguments".

It also reads only DEPTH-0 named arguments. A nested construction has its own
constructor:

    new ProcestToolProvider(
        caseReader: new ProcestCaseReader(
            settingsService: $settingsService,   <-- the INNER call's parameter
            logger: $logger,

Judging those against the outer constructor is what made the first attempt
delete valid arguments; that attempt was reverted, and this is its replacement.
phpstan, and by volume this was the bulk of what remained:

    138x PHPDoc tag @PARAM references unknown parameter: $container   (shillinq)
     62x                                                              (pipelinq)
     37x                                                              (decidesk)

ADR-083 removed the ContainerInterface parameter from the classes it converted
and left the @PARAM line above it. Removed only where the documented signature
genuinely has no $container — classes still using the availability-guarded
lookup keep both, verified on three of them (signature present, docblock intact).

Also:

  Dead catch - Throwable is never thrown in the try block
      getObjectService() became a property read, which throws nothing.

  Expression "$this->objectService" on a separate line does not do anything
      the old `$objectService = $container->get(...)` line survived as a bare
      expression after its right-hand side was removed.

Not touched here: `is_array()` on an ObjectEntityInterface, which phpstan says
always evaluates to false. That is 45 sites in shillinq alone and the correct
fix differs per branch — the array arm is dead now that find() returns an
entity, but what the surviving arm should read is a per-site question.
…ass shadowed

The conflict was in resolveContactOrganisation(), and development was right on
both counts:

  * the method TAKES $objectService as a parameter. My dangling-reference pass
    rewrote it to $this->objectService — it was meant to skip functions that
    receive the value, and here the skip did not fire.
  * the schema slug was renamed contactpersoon -> contactPerson on development.

A sweep for the same mistake found one more, in
UserProfileUpdatedEventListener::findContactPerson(), where a parameter was
likewise being shadowed by a property read. Both restored.

Neither would have failed loudly: the property exists and holds the same
service, so the code works — until a caller passes a DIFFERENT object service to
these helpers, which is the only reason they take one.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/softwarecatalog @ 1a45ba8

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-15 22:35 UTC

Download the full PDF report from the workflow artifacts.

findAll() is (array $config, bool $_rbac, bool $_multitenancy). These two calls
passed the register id and schema id positionally, so they landed on the two
booleans: the query ran unscoped across every register, with $_rbac set to a
register id and $_multitenancy to a schema id -- both truthy, so nothing failed
loudly and the wrong rows came back.

The sibling call 180 lines above already does it correctly, with register and
schema inside 'filters'. Matched that shape.

Verified the contract's signature against the real ObjectService on
openregister@development before changing the callers: they agree exactly, so the
callers were the wrong side. Pre-existing -- the untyped container lookup meant
no analyser could compare them.
@github-actions

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/softwarecatalog @ 844e9f1

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 01:21 UTC

Download the full PDF report from the workflow artifacts.

@rubenvdlinde
rubenvdlinde merged commit 0335cb2 into development Aug 16, 2026
55 of 81 checks passed
@rubenvdlinde
rubenvdlinde deleted the refactor/adr-084-type-hint-the-contract branch August 16, 2026 08:46
rubenvdlinde added a commit that referenced this pull request Aug 16, 2026
…ying (#531)

* fix(me): pass the two ADR-084 arguments the /me factory stopped supplying

`GET /api/softwarecatalog/api/me` returns 500 for every user on
`development` right now:

    Too few arguments to function
    OCA\SoftwareCatalog\Controller\ContactpersonenController::__construct(),
    11 passed in .../lib/AppInfo/Application.php on line 730
    and exactly 13 expected

ADR-084 (#519) added `$objectService` and `$organisationService` to
`ContactpersonenController::__construct()`. That controller is not
autowired: `Application.php` registers it with a hand-written factory
closure, added deliberately "for /me endpoint", and a hand-written
factory does not gain a constructor argument automatically.

Nothing we run catches that. `php -l` passes, phpcs/phpmd/psalm/phpstan
pass, and the controller's own unit tests pass because they construct the
controller themselves and never go through the factory. The first thing
that notices is a request. It was found by an end-to-end run, as the only
5xx URL in 176 requests — and it accounts for 42 of that suite's 47
failures.

The fix supplies both arguments from the same container entries the file
already uses twice (`ObjectServiceInterface::class` via the alias
registered at the top of `register()`, and OpenRegister's
`OrganisationService`).

`tests/Unit/AppInfo/CompositionRootArgumentsTest.php` closes the hole for
every hand-written factory in the composition root, not just this one. It
reads `Application.php`'s own source with PHP's tokeniser, extracts every
`new <Class>(...)`, and reflects the target constructor. It refuses to
report a vacuous pass: it asserts a floor on the number of call sites
parsed and on the number of classes reflected, and it keeps the set of
targets it cannot reflect explicit, because unreflectable is "unchecked",
not "clean".

* test(composition-root): resolve an unqualified class name against the file's own namespace

The first version of the parser resolved `use ... as Alias` after taking
the short name, and did not resolve an unqualified name against the
file's own namespace at all. Both mistakes push a call site into the
"could not reflect" bucket, and unreflectable reads as unchecked, which
reads as clean.

With both fixed, the set of factory targets this test cannot reflect in
softwarecatalog is EMPTY — all 40 call sites in the composition root are
actually checked — so the declaration is now asserted exactly rather than
as a subset.

* test(stubs): give the ObjectEntity stub the return types its contract declares

The unit suite does not run on `development`. It dies with

    Fatal error: Declaration of OCA\OpenRegister\Db\ObjectEntity::getUuid()
    must be compatible with
    OCA\OpenRegister\Contract\ObjectEntityInterface::getUuid(): ?string
    in tests/Stubs/Db/ObjectEntity.php on line 151

ADR-084 made the stub `implements ObjectEntityInterface`, but its five
abstract declarations kept their untyped, docblock-only signatures. PHP
refuses to declare a class whose abstract method is less specific than
the interface it satisfies, so the failure is a class-declaration fatal,
not a test failure: it aborts the run rather than reporting anything.
Under `tests/bootstrap.php` — the config CI uses — every stub is
`require_once`d at bootstrap, so the suite dies before test one and the
job reports zero tests.

One anonymous subclass (MergeOrganisatieServiceTest) needed the same four
return types for the same reason.

`getId()` and `setObject()` are deliberately left untyped: neither is on
the contract, so nothing constrains them.

This does not fix a single test. It makes the suite measurable:

    before: 50 tests, then a fatal (0 under tests/bootstrap.php)
    after:  Tests: 702, Assertions: 2656, Errors: 50, Failures: 12,
            Warnings: 3, Deprecations: 1, Skipped: 25

The 62 problems now visible are the rest of the ADR-084 fallout and are
NOT addressed here — 38 are test helpers still declaring a return type of
the concrete `OCA\OpenRegister\Service\ObjectService` while the code they
feed now takes `ObjectServiceInterface`, and 8 are the deleted
`$container` constructor parameter shifting every positional argument
after it. They need their own change.

---------

Co-authored-by: Conduction Release Bot <release-bot@conduction.nl>
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