Skip to main content

MEP 10. Known Gaps and Weakness Review

FieldValue
MEP10
TitleKnown Gaps and Weakness Review
AuthorMochi core
StatusInformational
TypeInformational
Created2026-05-08

Abstract

This MEP is the "uncomfortable truths" file. It catalogues every place where Mochi today is unsound, surprising, incomplete, or simply mis-sold. Each entry has the evidence (file and line), an impact assessment, and either a fix plan or an explicit "by design" note.

Motivation

A fixture pinned to current behaviour is not the same as endorsement. Some entries below are captured by fixtures so that a future fix is a deliberate breaking change rather than an accidental drift. The tier system labels how seriously each gap threatens the "type safe" claim.

Specification

Tier A: critical for the "type safe" claim

A1. AnyType is a true top type

Evidence. types/check.go:148-157 for the unify rule that lets AnyType match in either direction. Most builtins are declared with AnyType{} parameters at lines 393 to 562.

Impact. A value typed any can be passed to a function expecting int, indexed as a list, or compared with anything. There is no explicit cast required.

Plan.

  1. Capture the current behaviour in a fixture (tests/types/valid/any_top_silent_widen.mochi).
  2. Audit which builtins truly need any and rewrite the rest with parametric polymorphism once MEP 12 lands.
  3. Tighten unify so any requires an explicit as cast in either direction.

A2. null is a value of any type

Evidence. types/check.go:1708. The literal null is typed AnyType{} rather than producing an OptionType.

Impact. let x: int = null type checks today. Dereferencing x in arithmetic is a runtime error.

Plan.

  1. Wire OptionType into the inference of null.
  2. Introduce a T? syntactic shorthand for OptionType{T}.
  3. Add a check that requires explicit unwrap (? or match) before use as the underlying type.

A3. Mutation through immutable bindings via aliasing

Evidence. types/check.go:858-868 in the AssignStmt walk. The mutability flag is consulted before any index or field walk, so the direct path let xs = [1]; xs[0] = 2 and let p = Point{x: 1}; p.x = 2 are both rejected with T024 today. Pinned by tests/types/errors/mutate_through_let_index.mochi and tests/types/errors/mutate_through_let_field.mochi.

The remaining hole is aliasing. Copying a let-bound list (or struct, or map) into a var binds the same underlying storage at runtime, so a write through the var mutates the let too. tests/types/valid/mutate_through_let_alias.mochi pins the type checker accepting the alias write; the runtime read after the write returns the mutated value.

Impact. let does not guarantee that the value behind the binding does not change. A defensive reader cannot trust that a let-bound list seen earlier in a function still has the same elements after an unrelated call that received a copy of the binding.

Plan.

  1. Pick a discipline. Two candidates: copy-on-write at the alias boundary (let-bound aggregates clone before binding into a var), or invariance under aliasing where assigning a let-bound aggregate to a var of the same type is a checker error.
  2. If invariance, add error code (next free) T0xx: cannot alias an immutable aggregate into a mutable binding.
  3. Move the alias fixture from valid/ to errors/ once the rule lands.

A4. Match exhaustiveness not enforced

Evidence. The match check loop in types/check.go (lines around 2209 to 2284) types each arm but does not check that the union of arm patterns covers every variant of a UnionType scrutinee.

Impact. Removing a variant from a union does not surface as a compile error in code that matches on it. Users discover the gap at runtime.

Plan.

  1. Compute the variant coverage during match check.
  2. Allow a single wildcard or identifier pattern to cover the remainder, and warn (not error) if it shadows reachable cases.
  3. Emit a new error code for missing variants.

A5. as cast is unchecked

Evidence. types/check.go:1687-1688 resolves the target type and returns it. There is no compatibility check.

Impact. (5 as string).len type checks. The runtime then either crashes or silently coerces, depending on the path.

Plan.

  1. Define a small subtype check castOk(from, to) that allows numeric tower casts, union to variant casts (with a runtime discriminator check), and to any always.
  2. Reject other casts at type check time.

Tier B: surprising or incomplete

B1. Integer division mismatch between checker and runtime

Evidence. types/infer.go:116-118 says int / int produces int. At runtime 5 / 2 evaluates to 2.5. Probed against the v0.10.81 binary on 2026-05.

Impact. let x: int = 5 / 2; print(x) is accepted by the type checker and prints 2.5. The static type and the runtime value disagree. A later operation x + 1 then prints 3, which suggests an implicit narrowing back to int at the use site. The combination is genuinely unsound: the checker promises int and the program observes a float value flowing through.

Plan. Decide policy first.

  • Path A: rational division. Make / always return float for numeric operands. Document. Update inferBinaryType to drop the int/int special case. Add a div builtin or a // operator for truncating division.
  • Path B: truncating division. Make the runtime truncate, matching the checker. Add a fixture confirming 5 / 2 == 2.

Path A matches the runtime today. We recommend it.

B2. Operator precedence reduction is order-dependent on numeric mixes

Evidence. types/infer.go:120-138. The mix rules check isInt64 before isFloat and that ordering can produce different result types depending on operand position.

Impact. bigint - float and float - bigint may not produce the same type. Programs that rely on the result type mid-expression are fragile.

Plan. Replace the cascade with a small lattice and join semantics so the result depends only on the operand kinds, not on which side they appear on.

B3. List covariance without write protection

Evidence. types/check.go:172-189. unify(ListType{Int}, ListType{Any}) succeeds without restricting writes.

Impact. Once mutation through indices is tightened (A3), unsafe covariant aliasing becomes the next failure mode. Today the issue is masked because mutations slip through anyway.

Plan. After A3, treat list element type as invariant under aliasing. Allow read-only covariance only at expression positions that cannot be assigned through.

B4. Pure flag is set but never enforced

Evidence. The flag is set at builtin registration in types/check.go:393-562 and on user functions where the body is free of side effects. There is no rule that consumes it.

Impact. The flag is misleading. A programmer reading the code might trust it.

Plan. Either delete the flag, or add a small set of pure positions (struct field defaults, type alias arguments, query plan predicates) where impure calls are rejected.

B5. Generic functions parse but do not unify across calls

Evidence. TypeVar exists at types/check.go:104 and is used in unification but is not produced by inference of a function call's result. Each call site re-infers from the declared signature.

Impact. A function declared fun id<T>(x: T): T is callable but the caller always sees the type of x flow through the declaration in a loose way.

Plan. Wire TypeVar through inference. Build a small substitution solver so a call generates fresh variables and unifies arguments. Reject conflicting unifications with a new error code. See MEP 12.

B6. Query select fallback to any

Evidence. The aggregate dispatch in types/check.go:2401-2439 explicitly types count, sum, avg, min, max and falls through to general expression typing for everything else, often producing any.

Impact. A select foo(bar) where foo returns any does not raise even when bar is mistyped.

Plan. Extend the dispatch to keep stricter types and reject any in the projection unless explicitly cast.

B7. extern declarations are trust-the-author

Evidence. types/check.go:828-850 registers extern bindings without verifying the symbol exists in the import target.

Impact. Mismatch between declared type and actual symbol surfaces as a runtime failure, often deep inside a foreign call.

Plan. For Go and Python imports where we have introspection, validate the signature against the imported package.

B8. Recursive type definitions

Evidence. types/check.go:1179-1207 builds union types with a shared variants map so forward references resolve. Recursion in struct fields is permitted.

Impact. Soundness is not at issue but unbounded recursive structures can cause runtime memory issues. JSON serialisation can loop.

Plan. Document the policy. Add a fixture where a recursive linked list works. Add a runtime check for serialisation cycles.

B9. Shadowing without warning

Evidence. types/env.go:179, 187 allow rebinding a name in a child scope without diagnostic.

Impact. Subtle bugs. A user writes let x = ... in a nested block intending to shadow but accidentally reassigns.

Plan. Add an opt-in lint rule. Not a soundness issue.

Tier C: parser and surface quirks

C1. int | nil does not parse

Evidence. parser/parser.go:184-189. TypeRef does not list union as a constructor.

Impact. The natural way to write a nullable type fails.

Plan. Decide between two paths: a T? shorthand or full union types in TypeRef. Either resolves A2 once OptionType is wired through.

C2. Unary minus on RHS of comparison without parens

Evidence. parser/parser.go:53-56 documents the lexer rationale.

Impact. x == -1 requires parens. Inserting whitespace as x == - 1 does not help; the parser sees - followed by 1 as a missing-operand prefix and fails the same way. Surprising for new users.

Plan. Document in parser/README.md and in MEP 1. Long term, look at splitting the lexer rule so -1 is a number when not preceded by an ident or close bracket.

C3. Logic and stream features parse but do not run on the bytecode VM

Evidence. The bytecode compiler in runtime/vm/vm.go switch on statements does not include Agent, Stream, Emit, Fact, Rule, On, Intent.

Impact. Programs using these constructs run on the interpreter only. Building one of these programs into a bytecode binary silently removes the feature.

Plan. Either compile them or emit a clear error from the bytecode backend that says these constructs are interpreter only.

C4. The block comment regex does not nest

Evidence. parser/parser.go:49. /* ... */ matches the first */.

Impact. A literal */ inside a block comment terminates it early.

Plan. Document. Low priority.

C5. Reserved keywords accidentally steal user names

Evidence. The keyword regex at parser/parser.go:51 is enforced globally.

Impact. Adding a keyword silently breaks user code that used the new word as an identifier.

Plan. Add a checklist item to the release process: any new keyword must be tested against the existing fixture set and any third-party code that the team can scan.

Tier D: documentation drift

  • MEP 9 must be regenerated whenever fixture counts change.
  • Builtins listed in MEP 6 must match the actual code; today they do, but the list is hand-maintained.
  • The error code table in MEP 6 is hand-maintained too. Consider a generator that emits Markdown from types/errors.go.

Rationale

A weakness review that names files and lines is one we can act on. A vague list of "issues to fix someday" is one we cannot. We tier the items so the most consequential gaps get the most attention.

Backwards Compatibility

Each fix above is a breaking change for some programs. We pin current behaviour in fixtures so that the breakage is deliberate and traceable.

Reference Implementation

References to source files and lines are inline above.

Open Questions

  • Path A or B for B1. Recommendation is Path A (rational division). Need consensus before shipping the fix.
  • Soft versus hard any removal. Whether to keep any as an explicit opt-in cast target or eliminate it once polymorphism lands.

References

  • See MEP 7 for the soundness contract this document offends against.
  • See MEP 12 for the polymorphism upgrade that closes A1 and B5.

This document is placed in the public domain.