Add type checking to Laurel resolution pass#1121
Conversation
- Change resolveStmtExpr to return (StmtExprMd × HighTypeMd) - Add type checks for: - Boolean conditions in if/while/assert/assume - Numeric operands in arithmetic/comparison operations - Boolean operands in logical operations - Argument types matching parameter types in static calls - Argument types matching parameter types in instance calls - Assignment value type matching target type - Function body type matching declared output type - Report type mismatches as diagnostics (compilation continues) - Handle cascading errors: Unknown types are compatible with everything, UserDefined types skip strict checking (subtype relationships not tracked), void types skip assignment checks (statements don't produce values) Closes #1120
TCore is a pass-through type from Core that should not be checked during Laurel resolution. Without this, two identical TCore types (e.g. 'Core Any') would fail highEq (which has no TCore case) and produce spurious 'Type mismatch' diagnostics.
|
@keyboardDrummer-bot can you add a test that confirms various type checking errors are reported? |
Derive expected output count from the RHS type (MultiValuedExpr gives the arity, otherwise 1) instead of re-looking up the procedure. This ensures LHS and RHS arity always match for assignments.
Tests confirm that the following type errors are reported: - Non-boolean condition in if/assert/assume/while - Non-boolean operand in logical operators (&&) - Non-numeric operand in comparisons (<) - Assignment type mismatch (int := bool) - Function return type mismatch - Static call argument type mismatch
- Add checkSingleValued helper that detects MultiValuedExpr types used in expression position (e.g., as operands to PrimitiveOp) - Emit error: "Multi-output procedure '<name>' used in expression position" - Add ResolutionTypeTests.lean with test for assert multi(1) == 1
…naturally Instead of a dedicated 'Multi-output procedure used in expression position' error, multi-output calls in expression position now produce standard type mismatch errors like 'expected int, but got (int, int)'. - Remove checkSingleValued function and its call in PrimitiveOp - Remove MultiValuedExpr skip in checkAssignable - Add Eq/Neq operand compatibility check - Add formatType helper for nice MultiValuedExpr formatting - Skip assignment type check when arity already mismatches
tautschnig
left a comment
There was a problem hiding this comment.
checkBoolandcheckNumericpass-through forUserDefined(lines 381, 388). The comment says "constrained types may wrap bool / numeric", which is fair for constrained subtypes, but the check is by kind rather than by wrapped base type: aColoruser type will silently passcheckBool, andDogwill silently passcheckNumeric. In practice the downstream type-erasure path may catch these later, but the point of type checking here is to catch them early. Two options, either is fine:
- Inspect the resolved
UserDefinedtarget to see whether it's.constrainedTypewrapping a compatible base, and only pass it through in that case. - Keep the current permissive behaviour but add a comment and a test that explicitly documents "
Colorinif posslot silently passes — known limitation, tracked under #…".
-
checkAssignabletreats anyUserDefined/TCorepair as compatible (lines 399–402). This is the deliberate "subtype relationships not tracked here" trade-off, noted in the PR body. It meansvar x: Dog := new Catandvar x: CoreA := ∅CoreB-typed RHS both silently succeed. Worth a regression test that asserts no diagnostic is emitted for such cases, as a paper trail for the known limitation (so that when someone later does track subtyping, the test flips from "passes without complaint" to "correctly rejects"). -
Eq/NequsescheckAssignable source rhsTy lhsTy(line 571) — asymmetric for a symmetric operator, and the resulting diagnostic reads"Type mismatch: expected '{rhsTy}', but got '{lhsTy}'"which misleadingly frames the LHS as "wrong". For1 == "hello"the user sees"expected 'string', but got 'int'"— technically accurate under the checker's internal view but confusing. Consider either (a) dropping the check forEq/Neqentirely and relying on the caller to ensure the operands are in the same comparable category, or (b) rewording the diagnostic message for the symmetric case:"operands of '==' have incompatible types '{lhsTy}' and '{rhsTy}'". -
PrimitiveOp.StrConcathas no operand check (inside thematch op withat around line 575). If someone writes1 ++ "hello"it slides through. Trivial to add:for aTy in argTypes do checkAssignable source { val := .TString, source := source } aTy. -
Quantifier body type is assumed
.TBool(line 605) without a check that it actually is.forall x: int . x(body of type int) silently passes. Trivial to add:checkBool body'.source bodyTy. -
Arity guard
valueTy.val != HighType.TVoid(line 507) skips the check when the RHS is void. Correct for statement RHS likereturn/while, but relies on the checker never mis-tagging a value expression as void. Worth a comment spelling out why the void guard is there so a future refactor doesn't accidentally loosen it. -
Functional procedure body-type check is
isFunctional && body'.isTransparent(lines 691 and 731). The duplicated block betweenresolveProcedureandresolveInstanceProcedureis identical modulo the surrounding scope — easy to factor into acheckFunctionalBodyTypehelper.
Maintainability / refactoring:
-
Repeated pattern
do let (e', _) ← resolveStmtExpr a.val; pure e'. It appears at 15+ sites (invariants, decreases, preconditions, invokeOn, constant initializers, type-definition constraint/witness, field-target recursion, etc.). A one-line helper:private def resolveStmtExprExpr (e : StmtExprMd) : ResolveM StmtExprMd := do let (e', _) ← resolveStmtExpr e; pure e'
would compress each site to a one-liner and make it obvious which callers are intentionally discarding the type. See inline on
checkAssignable. -
Two test files with confusingly similar names.
ResolutionTypeCheckTests.lean(9 cases) andResolutionTypeTests.lean(1 case) both target resolution-time type checking. The naming distinction isTypeCheckvsTypewhich is not self-explanatory. Consider consolidating — either rename one to something unambiguous (e.g.,ResolutionMultiOutputTests.lean) or merge into a single file with clearly named sections. See inline.
Test coverage gaps: the nine scenarios in ResolutionTypeCheckTests.lean cover scalar operations well. Four additions would strengthen the suite materially:
- Instance call argument mismatch. The static-call case is covered;
.InstanceCalluses the samecheckAssignableloop with theselfparameter stripped, which is a logically distinct code path at line 611 and deserves its own test. - Constant initializer mismatch.
resolveConstantthreads through the new checker at line 808 but there's no test forconst x: int := true. - User-defined type pass-through documentation.
var x: Dog := new Catshould pass silently today. A test that asserts "no diagnostic emitted" pins the known limitation — when the hierarchy check lands later, this test flips and is a reminder to update. - Assignment target-count mismatch. The arity check at line 505 was the heart of review thread #1 but there's no test like
procedure multi() returns (a: int, b: int) opaque; … var x := multi()(one LHS, two RHS). Worth a fixture.
Proof-coverage suggestions. Mostly meta-code, but four invariants are worth stating:
- Soundness. If
resolveStmtExpr ereturns(e', τ)and no diagnostic is emitted, thene'is well-typed atτunder some formal type system. Stating this formally would require a semantic model for Laurel types; as a lighter-weight intermediate, prove type consistency: the returnedτfor aLiteralIntis always.TInt, etc. — which is immediate from the code but stating it pins the correspondence. - Cascade suppression. For every expression
e, if any sub-expression has type.Unknown, then no new diagnostic is emitted ate's level due to that sub-expression. This formalises the intent of theUnknownwildcards incheckBool/checkNumeric/checkAssignable— forget one of those wildcards in a future extension and the invariant fails. - Arity-match completeness.
targets'.length != expectedOutputCount ∧ valueTy ≠ .TVoid → arity diagnostic is emitted. Test #1's concern, formalised. - UserDefined symmetry.
checkAssignable _ tyA tyBis currently not symmetric in general (normal types usehighEq), but is symmetric whenever either operand isUserDefined/TCore/Unknown. A small lemma documenting when symmetry holds would make the asymmetric case inEq/Neq(observation 3) less of a UX footgun.
Inline comments on the two highest-impact items below.
|
@keyboardDrummer-bot please address the feedback from @tautschnig |
…solidate tests
- Add checkComparable helper for symmetric Eq/Neq error messages
("Operands of '==' have incompatible types 'X' and 'Y'")
- Extract resolveStmtExprExpr helper to reduce repeated pattern
- Add constant initializer type check in resolveConstant
- Merge ResolutionTypeTests.lean into ResolutionTypeCheckTests.lean
- Add tests: equality type mismatch, assignment target count mismatch,
UserDefined pass-through (documents known limitation)
- Update checkAssignable doc comment to mention TCore types
first implementation : blocks
discards the type synthesized
general design rules (one section per rule)
isConsistent now treats every TCore _ as consistent with everything, and no longer carves out UserDefined "Any". Per review: "Any" has no special meaning in Laurel, and TCore is being removed anyway. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…trata-org#1302) ## Summary The Python `for` loop is desugared into a `while` loop with an explicit counter. Before this PR, Python's `continue` skipped the counter increment, making the loop non-terminating. This PR fixes the desugaring so `continue` falls through to the increment, matching Python's semantics. ## The bug, in one Python example ```python for x in items: if some_condition(x): continue do_work(x) ``` In Python, `continue` skips the rest of the body and proceeds to the next iteration. With the old translation, `continue` skipped the rest of the body **and** the counter increment, so on every iteration where `continue` fired, the counter stayed the same and the loop ran forever on the same index. ## How the desugaring worked before `for x in iter: body` was translated to: ``` counter := 0 while (counter < len(iter)) { block continueLabel: { x := iter[counter] body counter := counter + 1 // ← inside the labeled block } } ``` `continue` in Python compiles to `exit continueLabel`. That exits the **whole labeled block** — past `body`, past `counter := counter + 1`, straight to the next while-condition check. Result: counter never advances when `continue` fires. Infinite loop. There was a secondary symptom too: `break` (which compiles to `exit breakLabel`, jumping out of the entire loop) was placed before `counter := counter + 1` in the same block, so `counter := counter + 1` became unreachable code after `break`. Recent stricter resolution passes flag this as a \`dead code after 'exit'\` diagnostic. ## What the fix does Move the counter increment **outside** the labeled block, so `continue` falls through to it: ``` counter := 0 while (counter < len(iter)) { { // outer unlabeled block block continueLabel: { x := iter[counter] body } counter := counter + 1 // ← outside the labeled block } } ``` Now `exit continueLabel` jumps to the end of the *labeled* block but stays inside the *outer* unlabeled block, so the counter increment runs. `break` (`exit breakLabel`) still jumps cleanly out of the whole loop. This also eliminates the \`dead code after 'exit'\` diagnostic, because nothing follows \`exit\` in the same block anymore. ## File changed - \`Strata/Languages/Python/PythonToLaurel.lean\`, the \`.For _ target iter body ...\` arm of \`translateStmt\`. 12 lines added, 3 removed. ## Why this matters now The bug existed on \`main2\` but didn't surface as a test failure because \`pyInterpret\` doesn't execute the loop's body symbolically — the structural translation passed verification, even though it would have hung at runtime. The PR adding type-checking to Laurel resolution (strata-org#1121) introduced a stricter dead-code check that flags the \`exit; counter := counter + 1\` pattern. PR strata-org#1121 will reference this fix as the prerequisite that lets its tests pass. ## Tests whose translation changes These tests use Python `for` loops and therefore go through the changed desugaring code path. **None of them change pass/fail outcome on `main2`** — the bug was previously masked because `pyInterpret` doesn't symbolically execute the loop body. They are listed here for reviewer awareness. Active tests (in `StrataTest/Languages/Python/tests/`): - `test_break_continue.py` — exercises both `break` and `continue` directly. The intended target of this fix. - `test_flag_pattern.py` - `test_for_else_break.py` - `test_for_loop.py` - `test_for_range.py` - `test_havoc_callee_after_hole_call.py` - `test_list_empty.py` - `test_loops.py` Pending tests (in `StrataTest/Languages/Python/tests/pending/`) — translation also changes; whether any move from pending → passing depends on downstream passes: - `test_accumulator_pattern.py`, `test_count_pattern.py`, `test_dict_iter_keys.py`, `test_dict_update_loop.py`, `test_dict_empty.py`, `test_flag_found.py`, `test_for_continue.py`, `test_for_count.py`, `test_for_break.py`, `test_for_else.py`, `test_for_range_manual.py`, `test_for_string.py`, `test_for_nested.py`, `test_for_sum.py`, `test_linear_search.py`, `test_list_len.py`, `test_list_of_objects.py`, `test_list_of_strings.py`, `test_list_sum.py`, `test_return_in_loop.py`, `test_scope_for_var.py`, `test_soundness_for_else_ignored.py` The `while`-loop tests (`test_while_loop.py` and the `while` portion of `test_loops.py`) are unaffected by this change. ## Test plan - [x] \`bash StrataTest/Languages/Python/run_py_interpret.sh\` — 215 passed, 4 skipped, 0 errors (no regressions) - [x] \`bash StrataTest/Languages/Python/run_py_analyze.sh\` (from \`StrataTest/Languages/Python/\`) — all tests pass - [x] \`lake build StrataTest\` — 617 targets, all pass 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: keyboardDrummer-bot <keyboardDrummer-bot@users.noreply.github.com>
clarify treatment of non terminal statements
….com:strata-org/strata into keyboardDrummer-bot/issue-1120-type-checking
Summary
Adds type checking to Laurel's
Resolution.leanas requested in #1120.Changes
resolveStmtExprnow returnsResolveM (StmtExprMd × HighTypeMd)— both the resolved expression and its synthesized type.Type checks added:
if/while/assert/assumemust beTBoolTInt,TReal,TFloat64)And,Or,Not,Implies, etc.) must beTBoolself)Diagnostics, not hard failures — type mismatches are reported via
ResolveState.errorsand compilation continues.Cascading error prevention:
Unknowntypes are compatible with everythingUserDefinedtypes skip strict assignability checks (subtype/inheritance relationships are not tracked during resolution)TVoidtypes skip assignment/output checks (statements likereturn/whiledon't produce values in the expression sense)MultiValuedExprtypes skip assignability checks (arity mismatch already reported separately)Unknownto avoid cascadingcomputeExprTypeinLaurelTypes.leanis unchanged — it continues to work alongside the new type checking.Callers updated to use the returned type from
resolveStmtExpr(e.g.,resolveBody,resolveProcedure,resolveInstanceProcedure,resolveConstant,resolveTypeDefinition).Testing
All existing tests pass (
lake build StrataTest— 592 jobs successful).Closes #1120"