Skip to content

GH-75: Document and cover the defensive guards, rather than delete them - #121

Merged
magicsunday merged 2 commits into
mainfrom
GH-75
Jul 19, 2026
Merged

GH-75: Document and cover the defensive guards, rather than delete them#121
magicsunday merged 2 commits into
mainfrom
GH-75

Conversation

@magicsunday

Copy link
Copy Markdown
Owner

Fixes #75.

The issue lists five "dead defensive code paths" to remove. Measured against HEAD, none is dead — each is a guard unreachable through the value converter's chain but live for another entry, and removing them would drop real protection.

Path Why it does not run on the chain Why it is kept
builtin null guard NullValueConversionStrategy claims every null first reached by a direct convert() — the strategies are public SPI
object-trait null guard same same
convertObjectValue non-object branch supports() returns false for the type reached by a direct call that skips supports()
ValueConverter LogicException passthrough (supports() always true, last) always matches invariant guard — without it the method falls off the end returning null
"collection class must be provided" throw extractCollectionType() returns a Type only when the class is set load-bearing assertion — narrows ?stringstring; PHPStan max fails without it

Each verified, not assumed:

  • A direct BuiltinValueConversionStrategy::convert(null, int) throws TypeMismatchException.
  • A direct DateTimeValueConversionStrategy::convert(BuiltinType, 'x') returns 'x' (the non-object branch).
  • Removing the collection-class throw fails PHPStan on two downstream calls.

Resolution: document, and cover — not remove

The acceptance criterion allows a documented reason for an unreachable defensive branch, and prefers null guards to be "live (covered by a test)". So each branch now states why it cannot occur on the normal path and why it stays; the four SPI/invariant guards also gain tests that drive them directly, turning "documented dead branch" into "live, covered branch".

Coordinated with #87

The architecture audit on the issue asked that the null guards not be deleted before the two-entry-points fix (#87, still open) — a single entry point may make them live through the chain too. Documenting-and-covering respects that: nothing here needs revisiting when #87 lands, and if #87 makes a guard live on the chain, its test simply gains a second caller.

Verification

composer ci:test green, verified by exit code: 382 tests, 1228 assertions; PHPStan max, Rector, CGL and CPD clean.

… four

The issue lists five "dead defensive code paths" to remove. Measured, none
is dead - each is a guard that is unreachable through the value converter's
CHAIN but live for another entry, and removing them would drop real
protection:

- The two null guards (builtin, object trait) throw for a null on a
  non-nullable target. Through the chain NullValueConversionStrategy claims
  every null first, so they never run there - but the strategy classes are
  public SPI, and a direct convert() call reaches them. Proven: a direct
  builtin convert(null, int) throws TypeMismatchException, as it should.
- The convertObjectValue non-object branch hands the value back for a type
  its own supports() would reject - reached only by a direct call skipping
  supports(). Proven the same way.
- The ValueConverter LogicException is unreachable while the passthrough
  strategy (supports() always true, last) is registered, and guards the
  invariant if that ever changes - otherwise the method would fall off the
  end returning null.
- The "collection class must be provided" throw is not a runtime guard at
  all but a load-bearing assertion: it narrows ?string to string for the
  calls below, and PHPStan max fails without it. Removing it, verified,
  breaks static analysis.

So the resolution is the acceptance criterion's "documented reason", not
removal: each branch now says why it cannot occur on the normal path and
why it is kept. The four SPI/invariant guards also gain tests that drive
them directly, turning "documented dead branch" into "live, covered
branch" - the criterion's preferred outcome for the null guards.

The architecture audit on the issue asked that the null guards not be
deleted before the two-entry-points fix (#87, still open), since a single
entry point may make them live through the chain too. Documenting and
covering rather than deleting respects that: nothing here has to be
revisited when #87 lands, and if #87 makes a guard live on the chain, its
test simply gains a second caller.
@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

…n the message

Review found my commit message claimed to cover four guards but only three
tests existed - and the missing one was exactly the object-trait null
guard. Removing its throw made convertObjectValue return null for a
non-nullable object target with the whole suite still green, the precise
regression this was meant to prevent. Added theObjectGuardRefusesANull...,
verified by mutation: neutralising the throw now fails it.

Dropped theObjectGuardHandsBackAValueForANonObjectType. It asserted the
exact silent-passthrough the object trait does for a type it does not
support - but the SPI interface promises nothing about convert() on an
unsupported type, so the test froze a misuse-only implementation detail as
contract, and a future fail-fast refactor would break it for no real
reason. The branch keeps its documented reason; it is defensive against a
protocol violation, which the acceptance criterion covers by documentation
rather than a test.

And the builtin guard test asserted only the exception type, which several
sites in that strategy throw. It now matches the message, so it pins this
guard rather than any TypeMismatchException.
@magicsunday
magicsunday merged commit 62535bb into main Jul 19, 2026
18 checks passed
@magicsunday
magicsunday deleted the GH-75 branch July 19, 2026 20:49
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.

Remove or make reachable the dead defensive code paths

1 participant