Skip to content

feat(irverify): reject a union that declares no variants - #360

Open
OmarAlJarrah wants to merge 4 commits into
mainfrom
feat/irverify-union-variants
Open

feat(irverify): reject a union that declares no variants#360
OmarAlJarrah wants to merge 4 commits into
mainfrom
feat/irverify-union-variants

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

An ir.Union carrying an empty Variants slice was reported by nothing.
irverify had no rule that read Variants at all, and the one place pass
reads them is checkUnionDiscriminator, which folds them into a membership set
for the discriminator mapping and returns immediately when the union declares no
discriminator:

$ git grep -n "Variants" 511e023 -- 'ir/irverify/*.go' 'pass/*.go' | grep -v _test.go
pass/validate.go:418:   variants := make(map[ir.TypeID]bool, len(u.Variants))
pass/validate.go:419:   for _, v := range u.Variants {

(Pinned to 511e023, the state this PR is against: run today it also
finds checkUnions, which is the change itself.)

So this verified clean and validated clean:

u := &ir.Union{TypeCommon: ir.TypeCommon{ID: "t/x/U", ...}}
doc := &ir.Document{Types: ir.TypeRegistry{u.ID: u}}

A union is the choice between its variants, so a union of none is a type no
value inhabits. It is not a shape any source format can express, so a union that
reaches the IR with none was built by a lowering that dropped every variant it
meant to add. That is our bug rather than a spec-author problem, which is what
makes it an irverify.Violation and not an ir.Diagnostic. Downstream it is
worse than the missing variants are on their own: an emitter switching over a
union's variants renders a type with no arms and no error, so the loss surfaces
as generated code that compiles and can never be constructed.

checkUnions reports ir/union-no-variants per offending union. Unions live
only in the type registry — invariant #3 keeps every named entity there and lets
no node embed another — so it iterates the registry and needs no walk, which is
also why it is not a walkChecks entry: those all owe a truncation flag, and a
registry iteration has nothing to truncate.

The two adjacent questions, settled by probe rather than by inspection

The issue asked whether a one-variant union and a repeated variant target are
the same defect. Compiling them decides it:

$ morphic compile oneof.yaml -skip-validate
oneOf: []                          -> refused by validation, lowers to a scalar
oneOf: [{$ref: Leaf}]              -> union, 1 variant
oneOf: [{$ref: Leaf}, {$ref: Leaf}]-> union, 2 variants, one target

Both come out of legal documents. Invariant #2 is what forbids a compiler
collapsing the single-variant case, and a repeated target is degenerate rather
than impossible. A Violation claims a compiler defect, so neither belongs in
this channel — if the repeated target is worth reporting at all it is a
spec-author problem for pass.Validate, which is deliberately out of scope here
and recorded as such in checkUnions' doc comment. Both are pinned as clean, so
a later tightening has to argue with a test rather than slip through.

Test plan

Four tests in ir/irverify, each confirmed to redden against a planted defect:

  • TestVerify_UnionWithNoVariantsIsAViolation — removing the checkUnions call
    from Verify fails it with "[]" should have 1 item(s), but has 0.
  • TestVerify_SingleVariantUnionIsClean — widening the predicate to
    len(u.Variants) > 1 fails it with
    Should be empty, but was [{ir/union-no-variants ...}].
  • TestVerify_RepeatedVariantTargetIsClean — pins the second passed shape.
  • TestVerify_NilTypeBesideAUnionDoesNotPanic — holds the report-only guarantee
    at this check; a nil registry entry stays checkRegistryKeys' to report. It
    plants both nil shapes, because only one of them can break anything: an
    untyped nil fails the *ir.Union assertion and is skipped whether the guard is
    there or not, while a typed nil satisfies it and the variant count behind it is
    a real dereference. Planting only the untyped one left ir.IsNilTypeDef
    removable from checkUnions with the whole suite green.

Every nil guard in the package was then mutation-tested for the same gap.
checkPrimKinds had it — pre-existing, from #319 — and
TestVerify_NilTypeBesideAPrimitiveDoesNotPanic closes it. checkIDs,
checkPrimIDs and checkRegistryKeys were already pinned; checkAuthKinds
iterates Auth, whose entries are structs, so it has no nil to guard. All five
guards are pinned now.

unionViolations filters by code, so a test asserting "clean" is not satisfied
by some unrelated violation being absent.

Coverage note, stated rather than implied: no committed fixture reaches this
check, because oneOf: [] is refused before it lowers and nothing else produces
an empty union. That is the intended state — the check guards against a future
lowering bug, not against a spec shape — so the unit fixtures above are what
exercise it, and the corpus is expected to stay silent.

main is merged in, and the full gate is green on the merged branch: gofmt,
go vet ./..., golangci-lint run (0 issues), go build ./...,
./scripts/check-coverage.sh.

Closes #318

An ir.Union carrying an empty Variants slice was reported by nothing.
irverify had no rule reading Variants at all, and the one place pass
reads them (checkUnionDiscriminator) folds them into a membership set
and returns immediately when the union declares no discriminator.

A union is the choice between its variants, so a union of none is a type
no value inhabits, and no source format expresses one. A union that
reaches the IR with none was built by a lowering that dropped every
variant it meant to add -- our bug, which is what makes it a Violation
rather than an ir.Diagnostic. Downstream it is worse than the missing
variants: an emitter switching over the variants renders a type with no
arms and no error, so the loss surfaces as generated code that compiles
and can never be constructed.

The two neighbouring shapes the issue raised are settled by what the
compiler actually produces rather than by inspection. oneOf with one
$ref lowers to a union of exactly one variant, and oneOf naming one $ref
twice lowers to two variants sharing a target; both come from documents
the specification allows, so neither is evidence of a compiler defect
and neither is reported here. Both are pinned as clean so a later
tightening has to argue with a test.
…ariants

# Conflicts:
#	ir/irverify/irverify.go
TestVerify_NilTypeBesideAUnionDoesNotPanic planted an untyped nil, which
fails the *ir.Union assertion and is skipped by a check with no guard at
all — so ir.IsNilTypeDef was removable from checkUnions with the whole
suite green. A typed nil satisfies the assertion, and the variant count
read behind it is a real dereference: with the guard gone, Verify panics
on one. Both shapes are planted now, and removing the guard reddens the
typed-nil case.
Every registry-iterating check in this package guards a nil entry with
ir.IsNilTypeDef, and each guard was mutation-tested for whether a test
holds it there. checkPrimKinds' did not: removing it left the suite green,
while a typed-nil *ir.Primitive panics Verify without it, since the
assertion succeeds and the kind read behind it is a real dereference.

The gap was the same one #360 fixes for checkUnions and has the same
cause — the existing cases plant an untyped nil, which fails the type
assertion and is skipped whether the guard is there or not. All five
guards are pinned now.
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.

irverify: nothing rejects a Union that declares no variants

1 participant