Skip to content

Fix compose() boundary detection under covariant Iso/Lens templates#30

Merged
veewee merged 2 commits into
mainfrom
fix/compose-stage-detection-issue-10
May 26, 2026
Merged

Fix compose() boundary detection under covariant Iso/Lens templates#30
veewee merged 2 commits into
mainfrom
fix/compose-stage-detection-issue-10

Conversation

@veewee
Copy link
Copy Markdown
Owner

@veewee veewee commented May 26, 2026

Summary

  • Adds a FunctionReturnTypeProvider hook on both Iso\Provider\ComposeProvider and Lens\Provider\ComposeProvider that walks adjacent compose() args and emits InvalidArgument when arg[i]'s right template is not mutually-contained with arg[i+1]'s left template.
  • Shared validator: src/Psalm/Compose/AdjacentTemplateValidator.php.
  • Re-activates the @psalm-suppress InvalidArgument markers in tests/static-analyzer/{Iso,Lens}/compose.php.

Why

After #9 made the Iso/Lens templates covariant, Psalm's standard template inference silently widened the shared boundary type between adjacent compose args (e.g. accepting compose(Iso<A,B>, Iso<C,C>, Iso<C,D>) by inferring the boundary as B|C), so the existing SA tests stopped catching the broken case. Covariance is intentionally kept (php-soap/encoding depends on storing Iso<int,string> in array<string, Iso<mixed,string>>).

Test plan

  • vendor/bin/psalm --no-cache is green (compose() suppressions are now used)
  • Manually flipped the suppressions to confirm the validator emits InvalidArgument on both Iso and Lens broken cases
  • vendor/bin/phpunit --no-coverage passes

Fixes #10

Follow-up (not in this PR)

Per discussion, a future major could port Iso/Lens to profunctor form Iso<S,T,A,B> with explicit per-slot variance — that would let standard inference handle compose boundary checks without this plugin hook. Tracking separately so the php-soap/encoding migration can be coordinated.

veewee added 2 commits May 26, 2026 09:56
After making Iso/Lens templates covariant (#9), Psalm no longer flagged
compose() calls where adjacent isos/lenses don't line up (e.g.
compose(Iso<A,B>, Iso<C,C>, Iso<C,D>)) because covariant template
inference silently widens the shared boundary to a union.

Add a FunctionReturnTypeProvider hook on the existing ComposeProvider
classes that walks adjacent args, extracts their TGenericObject template
params, and emits InvalidArgument when arg[i]'s right template and
arg[i+1]'s left template are not mutually contained (i.e. not
equivalent up to subtyping).

Fixes #10
@veewee veewee merged commit 2e61a6b into main May 26, 2026
12 checks passed
@veewee veewee deleted the fix/compose-stage-detection-issue-10 branch May 26, 2026 08:04
veewee added a commit that referenced this pull request May 26, 2026
Ports Iso and Lens from the two-template form (Iso<S, A> / Lens<S, A>)
to the four-template profunctor "STAB" form (Iso<S, T, A, B> /
Lens<S, T, A, B>), with a deliberate variance split across the layers:

- Concrete Iso / Lens classes are invariant on all four slots. This is
  where compose boundary detection lives: standard Psalm template
  inference catches mismatches via invariant unification, no plugin
  hack needed. The AdjacentTemplateValidator workaround from #30 is
  deleted.

- IsoInterface / LensInterface are @template-covariant on all four
  slots. This is where storage flexibility lives:
  Iso<Person, Person, string, string> fits in
  IsoInterface<mixed, mixed, mixed, mixed> slots, so consumers can
  keep heterogeneous optics in a single collection without losing
  the leaf types. Same trade as C#'s IEnumerable<out T>.

- Type-changing API exposed:
  Lens::set(S, B): T, Iso::from(B): T,
  Lens::update(S, callable(A): B): T. Type-changing optics like
  Lens<Person, AnonymizedPerson, string, HashedName> are now
  expressible.

- DynamicFunctionStorage compose providers (Iso + Lens) updated to
  emit 2N+2 templates with STAB pairing.

- SA tests rewritten for STAB; added compose_type_changing.php files
  exercising S != T and A != B end-to-end.

- Unit tests gain type-changing set/from tests using lightweight
  in-test fixtures. Runtime assertions unchanged.

- optional() typed as Lens<S|null, T|null, A|null, B|null> to match
  actual runtime semantics.

- properties() InvalidReturnType/InvalidReturnStatement suppress
  replaced with an inline @var A cast on the return value.

New docs/stab-optics.md walks a reader unfamiliar with optics through
the four template slots with concrete Person / AnonymizedPerson /
HashedName examples, a cheat sheet, and a "Footnote: a note on
variance" section explaining the concrete-vs-interface split.
Existing lens.md and isomorphisms.md cross-link; README pointer added.
veewee added a commit that referenced this pull request May 26, 2026
Ports Iso and Lens from the two-template form (Iso<S, A> / Lens<S, A>)
to the four-template profunctor "STAB" form (Iso<S, T, A, B> /
Lens<S, T, A, B>), with a deliberate variance split across the layers:

- Concrete Iso / Lens classes are invariant on all four slots. This is
  where compose boundary detection lives: standard Psalm template
  inference catches mismatches via invariant unification, no plugin
  hack needed. The AdjacentTemplateValidator workaround from #30 is
  deleted.

- IsoInterface / LensInterface are @template-covariant on all four
  slots. This is where storage flexibility lives:
  Iso<Person, Person, string, string> fits in
  IsoInterface<mixed, mixed, mixed, mixed> slots, so consumers can
  keep heterogeneous optics in a single collection without losing
  the leaf types. Same trade as C#'s IEnumerable<out T>.

- Type-changing API exposed:
  Lens::set(S, B): T, Iso::from(B): T,
  Lens::update(S, callable(A): B): T. Type-changing optics like
  Lens<Person, AnonymizedPerson, string, HashedName> are now
  expressible.

- DynamicFunctionStorage compose providers (Iso + Lens) updated to
  emit 2N+2 templates with STAB pairing.

- SA tests rewritten for STAB; added compose_type_changing.php files
  exercising S != T and A != B end-to-end.

- Unit tests gain type-changing set/from tests using lightweight
  in-test fixtures. Runtime assertions unchanged.

- optional() typed as Lens<S|null, T|null, A|null, B|null> to match
  actual runtime semantics.

- properties() InvalidReturnType/InvalidReturnStatement suppress
  replaced with an inline @var A cast on the return value.

New docs/stab-optics.md walks a reader unfamiliar with optics through
the four template slots with concrete Person / AnonymizedPerson /
HashedName examples, a cheat sheet, and a "Footnote: a note on
variance" section explaining the concrete-vs-interface split.
Existing lens.md and isomorphisms.md cross-link; README pointer added.
veewee added a commit that referenced this pull request May 27, 2026
Ports Iso and Lens from the two-template form (Iso<S, A> / Lens<S, A>)
to the four-template profunctor "STAB" form (Iso<S, T, A, B> /
Lens<S, T, A, B>), with a deliberate variance split across the layers:

- Concrete Iso / Lens classes are invariant on all four slots. This is
  where compose boundary detection lives: standard Psalm template
  inference catches mismatches via invariant unification, no plugin
  hack needed. The AdjacentTemplateValidator workaround from #30 is
  deleted.

- IsoInterface / LensInterface are @template-covariant on all four
  slots. This is where storage flexibility lives:
  Iso<Person, Person, string, string> fits in
  IsoInterface<mixed, mixed, mixed, mixed> slots, so consumers can
  keep heterogeneous optics in a single collection without losing
  the leaf types. Same trade as C#'s IEnumerable<out T>.

- Type-changing API exposed:
  Lens::set(S, B): T, Iso::from(B): T,
  Lens::update(S, callable(A): B): T. Type-changing optics like
  Lens<Person, AnonymizedPerson, string, HashedName> are now
  expressible.

- DynamicFunctionStorage compose providers (Iso + Lens) updated to
  emit 2N+2 templates with STAB pairing.

- SA tests rewritten for STAB; added compose_type_changing.php files
  exercising S != T and A != B end-to-end.

- Unit tests gain type-changing set/from tests using lightweight
  in-test fixtures. Runtime assertions unchanged.

- optional() typed as Lens<S|null, T|null, A|null, B|null> to match
  actual runtime semantics.

- properties() InvalidReturnType/InvalidReturnStatement suppress
  replaced with an inline @var A cast on the return value.

New docs/stab-optics.md walks a reader unfamiliar with optics through
the four template slots with concrete Person / AnonymizedPerson /
HashedName examples, a cheat sheet, and a "Footnote: a note on
variance" section explaining the concrete-vs-interface split.
Existing lens.md and isomorphisms.md cross-link; README pointer added.
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.

Compose stage detection is broken

1 participant