Skip to content

Return/assignment checks skip every nominal annotation: type aliases, TypeAlias, and user classes are all unverifiable #378

Description

@MelbourneDeveloper

Summary

Return-type and assignment checking is skipped entirely whenever the annotation is a nominal name — a PEP 695 type alias, a legacy TypeAlias, an implicit alias, a class defined in the same file, or an imported class. The declared type is parsed from annotation source text into InferredType::Named(..), and every Named is then treated as unverifiable and dropped.

The practical effect is that -> MyAlias and -> MyClass silently disable the return check that -> int performs correctly.

Reported from the web playground; reproduced identically on the CLI, so this is not a WASM-only difference. Reproduced on a clean cargo build --release of main @ 5b756d3.

Reported reproduction

def answer () -> MyStr:
    return "not an int"

type MyStr = int

print (answer ())

Basilisk: All checked. No issues found. Expected: a return-type mismatch — MyStr is int, and "not an int" is a str.

Blast radius

Every one of these is silent. Only the last (a direct builtin annotation) is caught.

# 1. PEP 695 alias, declared after use  — SILENT
def answer() -> MyStr: return "not an int"
type MyStr = int

# 2. PEP 695 alias, declared before use — SILENT  (so it is not a forward-reference problem)
type MyStr = int
def answer() -> MyStr: return "not an int"

# 3. implicit alias — SILENT
MyStr = int
def answer() -> MyStr: return "not an int"

# 4. explicit TypeAlias — SILENT
from typing import TypeAlias
MyStr: TypeAlias = int
def answer() -> MyStr: return "not an int"

# 5. alias of an alias — SILENT
type A = int
type B = A
def f() -> B: return "nope"

# 6. alias nested in a container — SILENT
type MyStr = int
def f() -> list[MyStr]: return ["nope"]

# 7. user-defined class in the same file — SILENT
class Foo: ...
def f() -> Foo: return "not a Foo"

# 8. imported class — SILENT
from decimal import Decimal
def f() -> Decimal: return "not a Decimal"

# 9. parameter position, alias — SILENT
type MyStr = int
def g(x: MyStr) -> None: ...
g("nope")

# 10. annotated assignment, alias — SILENT
type MyStr = int
x: MyStr = "not an int"

# --- CONTROL: identical code with the builtin spelled directly — CAUGHT ---
def answer() -> int: return "not an int"
# error[returns_compatibility]: return type mismatch: LiteralString is not assignable to int
def f() -> list[int]: return ["nope"]
# error[returns_compatibility]: return type mismatch: list[LiteralString] is not assignable to list[int]

Cases 7 and 8 are the important ones: this is not an alias bug, it is a nominal type bug. Aliases are just the most visible symptom.

Mechanism

crates/basilisk-checker/src/rules/returns_compatibility.rs:103 builds the declared type from the raw annotation text:

let Some(ann_text) = slice_span(&module.source, ann_span) else { continue; };
let declared_type = InferredType::from_annotation(ann_text);

Nothing consults module.type_statements, module.classes, or the import table, so MyStr / Foo / Decimal all land in InferredType::Named(..).

crates/basilisk-checker/src/rules/shared.rs:562 then discards them:

pub(crate) fn is_unverifiable_return_type(ty: &InferredType) -> bool {
    match ty {
        InferredType::Named(_) | InferredType::Literal(_) => true,
        ...

with the (accurate) rationale in its doc comment: "Named: protocols/classes/aliases … need class-hierarchy/structural analysis the return rules cannot perform." The recursion through Optional/List/Set/Dict/Union/Tuple/Callable is why case 6 is silent too — one Named anywhere in the annotation poisons the whole check.

returns_compatibility_2.rs gates on the same shared helper, so both sibling return rules go quiet together.

Expected

An alias is transparent: type MyStr = int means MyStr is int, so -> MyStr must produce exactly the diagnostic that -> int produces. Nominal class annotations must be resolved to their class and checked against the class hierarchy.

Why conformance did not catch this

The upstream suite exercises alias declaration rules and assignability through legacy aliases in files that Basilisk passes; it does not contain a case that pins "return a str literal from a function annotated with an alias-of-int". A 100% score is therefore fully compatible with this gap. See also the related conformance blind spot noted in #371.

Fix direction

Annotation → type resolution must go through a real name-resolution step (alias table → class table → import table → typeshed) before assignability runs, rather than pattern-matching annotation source text. Tracked as a first-class deliverable in CHECKER-TYPE-NARROWING-INFERENCE-PLAN.md NARROWPLAN-ANNOTATION-RESOLUTION.

Refs #324, #372, #282, #290.

Metadata

Metadata

Labels

criticalCritical severity — fix immediately

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions