Skip to content

Fix #14: an overload member's signature is fixed at its definition - #112

Merged
assapir merged 3 commits into
mainfrom
fix/overload-return-type
Aug 14, 2026
Merged

Fix #14: an overload member's signature is fixed at its definition#112
assapir merged 3 commits into
mainfrom
fix/overload-return-type

Conversation

@assapir

@assapir assapir commented Aug 14, 2026

Copy link
Copy Markdown
Owner

What was broken

Verified on main before writing code (finding 12 taught us premises go stale — this one does not). An overload member's return type defaulted to Num when the definition omitted it and was corrected only after the member's body was checked, so what a call saw depended on where it sat relative to the definition. Four distinct symptoms, all live:

Program Before
Forward call, unannotated member, stale Num matches a real member check passes, run❌ Overload not found: g$N
Forward call, unannotated member, no Num member exists bogus No overload of 'g' matches (Num). Candidates: (Text), (Bool)
Caller annotated -> Text bogus Type mismatch: expected Text, got Num — a type nobody wrote
Self- or mutually recursive unannotated member returning non-Num unwritable at all

And a second half of the same finding, independent of annotations: members were registered in a pre-pass, so a call could resolve against a definition further down the file that codegen then had no symbol for. A fully annotated program:

even = (n :: Num) -> Bool => n == 0 ? true : odd(n - 1)   ~ odd defined below
odd  = (n :: Num) -> Bool => n == 0 ? false : even(n - 1)

check passes, run❌ Overload not found: odd$N. That is the checker/codegen contract break the finding names, and the source of the error string it quotes.

What changed (maintainer's decisions)

1. A member must annotate its return type, as it already had to annotate every parameter. Overload::ret is now Option<Type> — omitted means unknown, not Num, so the placeholder cannot answer a call — and the omission is reported where it bites:

  • at the call that needed the result type: cannot call 'g': its overload member (Num) has no return type annotation — annotate it, since exact dispatch needs the full signature
  • at the definition when nothing calls it: overload member 'g' (Num) has no return type annotation — …

The post-hoc refinement from the body is gone: adopting the inferred body type would make a member's signature depend on where a call sits, which is the disease. Annotating fixes it, and a recursive member becomes expressible (p = (n :: Num) -> Text => n == 0 ? "done" : p(n - 1) now compiles and runs).

2. No hoisting. A member joins its set as its definition is reached, so a call resolves only against the members above it — the rule plain functions already followed (^ = () -> Num => later() has always been Undefined variable 'later'). A forward call now says so: cannot call 'odd' before its definition — Quilon resolves names top to bottom; move the definition above this call. A definition is still in scope for its own body, so self-recursion is unaffected. Mutual recursion between top-level functions is not expressible — it never worked, it only looked type-checked.

Consequences worth flagging: an unannotated comparison-operator overload (==, <=, …) now asks for the annotation instead of reporting that it must return Bool (the actionable message wins); and Overload::builtin was deleted, since the refinement pass was its only reader.

Coverage

  • 8 checker tests: call-site and definition-site anchoring, refinement never rescuing an unannotated member, recursion rejected-then-working, forward call rejected, mutual recursion rejected, a call resolving against only the members above it, and the comparison-operator case.
  • 2 JIT exit-code tests: a recursive annotated member runs; a call uses the member defined above it.
  • examples/overload_dispatch.ql extended (self-asserting, in the examples gate): a member defined above its caller, and a recursive member reaching itself.
  • Docs: LANGUAGE.md gains a "Names resolve top to bottom" section (stating the rule for plain functions and members, and that mutual recursion is therefore not expressible) plus the return-annotation requirement in the overloading section; CLAUDE.md's overloading bullet updated; CHANGELOG under Unreleased.

Notes

Gate

cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings, and the full suite under RUSTFLAGS=-D warnings are clean (-- --test-threads=1; under default parallelism tail_call_test hits the pre-existing #98 GC abort, reproducible on unmodified main).

🤖 Generated with Claude Code

assapir and others added 3 commits August 14, 2026 18:38
An overload member's return type used to default to `Num` when the definition omitted
it, corrected only after the member's body was checked — so what a call saw depended on
where it sat relative to that definition. A call above it resolved against the
placeholder and either passed `quilon check` and failed at runtime with
`Overload not found: g$N`, or was rejected complaining about a type nobody wrote
(`expected Text, got Num`). A recursive member returning anything but `Num` was
unwritable for the same reason.

Members now register with `ret: Option<Type>` — omitted means unknown, not `Num`, so the
placeholder cannot answer a call — and the omission is reported at the call that needed
the result type, or at the definition when nothing calls it. Annotating fixes it, which
also makes a recursive member expressible.

The other half of the same finding: members were registered in a pre-pass, so a call
could resolve against a definition further down the file that codegen then had no symbol
for — a fully annotated program with two mutually recursive members passed the checker
and died with `Overload not found: odd$N`. A member now joins its set as its definition
is reached, so a call resolves only against the members above it, matching the rule plain
functions already followed. Such a forward call is a compile error naming the situation;
a definition is still in scope for its own body, so self-recursion is unaffected, while
mutual recursion between top-level functions is not expressible (it never was — it only
looked type-checked). LANGUAGE.md now states the top-to-bottom rule outright.

`Overload::builtin` went with the refinement pass that was its only reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…of it

v0.9.1 is tagged, so its section is history: the two breaking overload entries the
merge folded into it move up to a fresh Unreleased section instead.
@assapir
assapir merged commit d722287 into main Aug 14, 2026
2 checks passed
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