Skip to content

feat(gates): gate-83 contract-surface-shift — declaring a magic method breaks every consumer - #467

Closed
rubenvdlinde wants to merge 4 commits into
mainfrom
feat/gate-83-contract-surface-shift
Closed

feat(gates): gate-83 contract-surface-shift — declaring a magic method breaks every consumer#467
rubenvdlinde wants to merge 4 commits into
mainfrom
feat/gate-83-contract-surface-shift

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

What

Adds gate-83 contract-surface-shift: a diff-scoped gate that fails when a method on a published contract moves between the MAGIC surface (an @method docblock tag routed through __call()) and the DECLARED surface, unless the change is annotated.

Why

PHPUnit picks its mock builder on exactly that distinction:

addMethods()   throws CannotUseAddMethodsException  if the method EXISTS
onlyMethods()  throws CannotUseOnlyMethodsException if it does NOT

So every consumer that doubles the class has hard-coded an assumption about which surface each method sits on. Moving one breaks them with no commit in their repositories.

Measured, not hypothetical. On 2026-08-15 openregister#2498 published the ObjectService/ObjectEntity interfaces, which forced getUuid(), getRegister() and getSchema() to be declared:

repo fallout
opencatalogi 42 errors, all 6 PHPUnit matrix cells red
decidesk 1 failure

Both development branches went red on commits that had passed hours earlier, and the failures first surfaced on unrelated dependency PRs, where they read as those PRs' fault. Finding that out cost a full baseline-comparison pass across 19 repositories.

Why it lives on the producer side

It cannot be written in the consuming repo. The hydra-gates job checks out exactly two things — the app and the gates package — so a consumer-side checker has no access to the real class and would resolve every external double as "unknown". It would report ZERO findings for the one change that matters, which reads as a pass. The authority for what is declared lives with the class, so the check lives there too.

The escape hatch

@contract-shift <category> — <reason>

Categories are closed: announced, internal-only, new-contract. Same shape as gate-16's @spec exclude and gate-61's @listener-placement. A bare tag or an unknown category fails.

Verification

In a real runner run, against the commit that caused the outage:

[gate-83] contract-surface-shift: FAIL — 5 undeclared shift(s) ...
[hydra-gates] COVERAGE: 64 of 69 declared gates reported a result

It names all five shifted methods — the three that broke consumers plus getOwner/getOrganisation, which nobody happened to have doubled.

Negative control, a diff touching no contract file:

[gate-83] contract-surface-shift: NOT APPLICABLE — the diff against 'HEAD~1'
touched no published-contract file, so no surface shift could occur.

Named, not silent, and still counted in COVERAGE.

Suite (scripts/lib/test_check_contract_surface_shift.sh, auto-discovered by run-helper-suites.sh) — 9 assertions, all paired positive/negative: both shift directions, the annotation grammar, empty scope, not-applicable, unresolvable-base-fails-closed, and the terminal summary the runner greps for.

One of those assertions earned its place immediately

The bare-annotation case caught a live bug in the first draft. The separator was \s*, which matches newlines — so on a reason-less @contract-shift announced the match ran past the line end and captured the docblock's closing */ as the "reason". Non-empty, so the no-reason branch never fired and the escape hatch was silently open to any bare tag. Nothing else would have found it.

…d breaks every consumer

A method on a published contract is served either DECLARED or MAGIC (an
`@method` tag routed through __call()). PHPUnit picks its mock builder on
exactly that distinction — addMethods() refuses a method that exists,
onlyMethods() refuses one that does not — so moving a method between the two
surfaces breaks every consumer that doubles it, with no commit in their repos.

Measured, not hypothetical: openregister#2498 published the
ObjectService/ObjectEntity interfaces, forcing getUuid()/getRegister()/
getSchema() to be declared. opencatalogi went red on 42 errors across all 6
PHPUnit cells, decidesk on 1. Both development branches broke on commits that
had passed hours earlier, and it surfaced on unrelated dependency PRs where it
read as their fault.

The gate is on the PRODUCER side because it cannot work anywhere else: the
gates job checks out only the app and the gates package, so a consumer-side
checker cannot see the real class and would resolve every external double as
unknown — reporting zero for the one change that matters, which reads as a
pass. The authority lives with the class, so the check does too.

Diff-scoped; fails unless the shift carries a reason-bearing
`@contract-shift <announced|internal-only|new-contract> — <reason>`.

Verified in a real runner run against the commit that caused the outage: FAIL
naming all five shifted methods, counted in COVERAGE (69 declared). Negative
control on a diff touching no contract file: NOT APPLICABLE, named rather than
silent. The 9-assertion suite is auto-discovered by run-helper-suites.sh; its
bare-annotation case caught a live bug in the first draft, where a \s*
separator crossed the newline and captured the docblock's closing */ as the
reason, leaving the escape hatch open to any reason-less tag.
…fix SC2015

Same two defects the gate-84 branch hit, and both are real here too.

Gate-83's `if [ -d lib/Contract ]` guard made it emit NOTHING in every repo
that consumes a contract rather than publishing one — 18 of the 19 — and a
silence is indistinguishable from a pass. That is the exact failure
--require-full-coverage exists to catch, and the package's own
all-not-applicable fixture catches it.

ShellCheck SC2015: `A && ok || no` is not if-then-else; the failure branch also
runs when the check passes and the reporter fails, so one assertion could print
both PASS and FAIL.

Verified: tests/test-hydra-gates-bin.sh rc 0, and the gate-83 suite is still
9 of 9.
…rrexit

The acceptance ratchet refuses a gate that is declared by the runner and
covered by neither a fixture bundle nor a reasoned row. gate-83 cannot have a
bundle: its subject is a TRANSITION between the magic and declared surfaces, so
it is meaningless without two commits, and a planted/clean pair is two
directory states with no history between them. Both arms would report EMPTY
SCOPE and the planted assertion would grade a scope fault as a gate defect.

Records the reason rather than weakening the ratchet, and names what actually
covers the gate: scripts/lib/test_check_contract_surface_shift.sh builds real
git fixtures with two commits (9 assertions, both shift directions, the
annotation grammar, unresolvable-base-fails-closed), and the gate was replayed
against openregister@83e8798a4 — the commit that caused the outage it exists
for — where it FAILs and names all five shifted methods.

Also restores with 'set +e' rather than 'set -e': errexit off is the state this
script runs in, and re-arming it would abort the run on the first non-zero
anything downstream.

Verified: acceptance matrix rc 0, errexit discipline rc 0, package invariants
rc 0.
ShellCheck SC2015 on eight assertion sites, not the one I fixed first. Every
`[ rc = n ] && ok ... || no ...` reads like if-then-else and is not: the
failure branch also runs when the check passes and the reporter fails, so a
single assertion could print both PASS and FAIL — a suite that lies in the
direction of looking fine.

Replaces them with an `expect_rc` helper that branches properly, which also
makes each assertion one line instead of three. Still 9 of 9.
@rubenvdlinde

Copy link
Copy Markdown
Contributor Author

Superseded by the rebased branch. #469 landed first and both branches added a gate block to the same region of run-hydra-gates.sh, so this one went CONFLICTING. Re-applied cleanly on top of the new main rather than hand-resolving a three-way conflict in a 10,500-line runner — same change, plus the two invariants the package's own CI caught here (the not-applicable declaration, and eight SC2015 assertion sites).

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