Skip to content

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

Open
OmarAlJarrah wants to merge 1 commit into
mainfrom
feat/irverify-union-variants
Open

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

Conversation

@OmarAlJarrah

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:

$ grep -rn "Variants" --include='*.go' ir/irverify/ pass/ | 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 {

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.

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.

Full gate green: gofmt, go vet ./..., golangci-lint run (0 issues),
go build ./..., ./scripts/check-coverage.sh (all 4952 statements covered).

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.
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