From ab291b74499fa5b0b9ba8b483a562167da0ac51d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 11:38:25 +0000 Subject: [PATCH 1/3] analysis: check return-type compatibility (OWN035) An external review of the very first main flagged two analyzer/codegen divergences that survived into the current tree: `lower_return` silently dropped a non-owned or empty return symbol to None, so the analyzer treated the function as a valid terminal while codegen printed the raw AST `return x;` -- emitting uncompilable C# the checker had accepted: * `fn f(n: int) -> Buffer { return n; }` -> `Buffer f(int n){ return n; }` * `fn f() -> Buffer { return; }` -> `Buffer f(){ return; }` Diagnose the mismatch instead of dropping it (new OWN035): a value-less `return;` in a function with a return type, a value returned from a function with none, and a non-owned value returned where an owned resource is required. `emit` then refuses (its existing contract), so no bad C# is produced. Returning an owned resource into a `-> Resource` stays clean (still escapes), and a returned borrow stays OWN004. Tests: 3 OWN035 cases + a clean bare-`return;`-in-void case (analysis 104/104). (The deeper fix the review recommends -- generate from the checked CFG/effects rather than the raw AST so the two cannot diverge -- remains roadmap; catching it in the checker + refusing to emit is the sound MVP behaviour.) --- ownlang/cfg.py | 34 ++++++++++++++++++++++++++++++++-- ownlang/diagnostics.py | 1 + tests/run_tests.py | 8 ++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/ownlang/cfg.py b/ownlang/cfg.py index 6577938e..b2d1c6c9 100644 --- a/ownlang/cfg.py +++ b/ownlang/cfg.py @@ -492,15 +492,45 @@ def lower_if(self, st: A.If, cur: Block) -> Block | None: return merge def lower_return(self, st: A.Return, cur: Block) -> Block | None: + ret = self.fn.ret sym: Symbol | None = None - if st.var is not None: + if st.var is None: + # `return;` with no value — only valid in a function with no return + # type. Otherwise the analyzer would treat the function as a valid + # terminal and codegen would emit `return;` from a non-void method. + if ret is not None: + self.diags.append(Diagnostic( + "OWN035", + f"'{self.fn.name}' returns '{ret.name}' but this 'return' " + f"provides no value", st.line)) + else: sym = self.lookup(st.var, st.line) - if sym is not None and sym.kind == Kind.BORROW: + if ret is None: + # returning a value from a function with no declared return type + # lowers to `return x;` inside a `void` method — uncompilable. + if sym is not None: + self.diags.append(Diagnostic( + "OWN035", + f"'{self.fn.name}' has no return type but returns " + f"'{st.var}'", st.line)) + sym = None + elif sym is not None and sym.kind == Kind.BORROW: self.diags.append(Diagnostic( "OWN004", f"'{st.var}' is a borrow and cannot be returned (it would " f"outlive the resource it borrows)", st.line)) sym = None + elif (not ret.borrowed and ret.name in self.resource_names + and sym is not None and sym.kind != Kind.OWNED): + # the return type is an owned resource, but the returned value is + # not one (e.g. `return n;` where n is a plain int). Dropping the + # symbol silently is what let codegen emit an uncompilable + # `return n;` from a `-> Buffer` method. + self.diags.append(Diagnostic( + "OWN035", + f"'{self.fn.name}' returns '{ret.name}' but '{st.var}' is " + f"not an owned resource", st.line)) + sym = None elif sym is not None and sym.kind == Kind.PLAIN: sym = None cur.instrs.append(Return(sym, st.line)) diff --git a/ownlang/diagnostics.py b/ownlang/diagnostics.py index 131ea41e..1a17406e 100644 --- a/ownlang/diagnostics.py +++ b/ownlang/diagnostics.py @@ -57,6 +57,7 @@ class Severity(Enum): "OWN032": "owned resource copied without 'move'", "OWN033": "function must return a value on all paths", "OWN034": "operation requires an owned resource", + "OWN035": "return type mismatch", # ---- extern / call boundary ---- "OWN040": "call to an undeclared function (unknown calls are forbidden)", "OWN041": "call argument mismatch", diff --git a/tests/run_tests.py b/tests/run_tests.py index 03cccf66..de811ec0 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -137,6 +137,14 @@ def codes(src: str) -> list[str]: "fn f(){ let a = acquire Buffer(1); let b = a; release a; }", ["OWN032"]), ("missing_return", "fn f() -> Buffer { let b = acquire Buffer(1); release b; }", ["OWN033"]), + ("return_plain_as_resource", + "fn f(n: int) -> Buffer { return n; }", ["OWN035"]), + ("return_empty_as_resource", + "fn f() -> Buffer { return; }", ["OWN035"]), + ("return_value_from_void", + "fn g(n: int){ return n; }", ["OWN035"]), + ("ok_bare_return_void", + "fn f(){ let b = acquire Buffer(1); release b; return; }", []), ("loop_rejected", "fn f(){ while (x) { use x; } }", ["OWN020"]), ("async_rejected", "fn f(){ async { use x; } }", ["OWN020"]), From 5179aa5f5f2173e20e6c86d60c8989e63571d298 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 14:25:15 +0000 Subject: [PATCH 2/3] tests: lock in an ownership/borrow soundness gallery Bringing the ownership checker -- the core of the PoC -- to a trustworthy state. An adversarial pass over the loans/permissions rules (~30 hand-built edge cases) found zero false negatives and zero false positives: every ownership/borrow violation is flagged, every safe program is accepted. The holes we had been finding all lived in codegen and the return-type edge, not in the ownership lattice itself. Capture the genuinely-new corners as permanent regression cases (16): borrow of a moved-from name, a void return that leaks inside a borrow, double consume, consuming/moving a borrow binding, a mutating call under a live shared borrow (OWN006), an inner acquire that leaks inside a borrow, shadowed binding names, return-of-moved + leak, and five clean programs guarding against the checker becoming over-strict (sequential exclusive borrows, consume on both arms, move chains, nested shared borrows). Analysis suite: 120/120. --- tests/run_tests.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/run_tests.py b/tests/run_tests.py index de811ec0..c77330d3 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -168,6 +168,56 @@ def codes(src: str) -> list[str]: "fn f(){ let a = acquire Buffer(1); let b = acquire Buffer(2); " "release b; use b; }", ["OWN001", "OWN002"]), + # ---- ownership/borrow soundness (adversarial) ---- + # Clean programs that must NOT be rejected (false-positive guard). + ("ok_sequential_mut_borrows", + "fn f(){ let b = acquire Buffer(1); borrow_mut b as m1 { use m1; } " + "borrow_mut b as m2 { use m2; } release b; }", []), + ("ok_consume_both_arms", + "fn f(){ let b = acquire Buffer(1); if (c) { Store(b); } " + "else { Store(b); } }", []), + ("ok_move_chain", + "fn f(){ let a = acquire Buffer(1); let b = move a; let c = move b; " + "release c; }", []), + ("ok_temp_borrows_then_consume", + "fn f(){ let b = acquire Buffer(1); Hash(b); Fill(b); Store(b); }", []), + ("ok_nested_shared_borrows", + "fn f(){ let b = acquire Buffer(1); borrow b as s { borrow b as t " + "{ use t; } } release b; }", []), + # Bad programs that must be rejected (false-negative guard). + ("borrow_moved_name", + "fn f(){ let a = acquire Buffer(1); let c = move a; borrow a as s " + "{ use s; } release c; }", ["OWN005"]), + ("release_moved_name", + "fn f(){ let a = acquire Buffer(1); let c = move a; release a; " + "release c; }", ["OWN005"]), + ("void_return_in_borrow_leaks", + "fn f(){ let b = acquire Buffer(1); borrow b as s { use s; return; } }", + ["OWN001"]), + ("double_consume", + "fn f(){ let b = acquire Buffer(1); Store(b); Store(b); }", ["OWN002"]), + ("consume_borrow_binding", + "fn f(){ let b = acquire Buffer(1); borrow b as s { Store(s); } " + "release b; }", ["OWN034"]), + ("mut_call_under_shared", + "fn f(){ let b = acquire Buffer(1); borrow b as s { Fill(b); } " + "release b; }", ["OWN006"]), + ("leak_inner_acquire_in_borrow", + "fn f(){ let a = acquire Buffer(1); borrow a as s { let b = acquire " + "Buffer(2); use s; } release a; }", ["OWN001"]), + ("move_borrow_binding", + "fn f(){ let b = acquire Buffer(1); borrow b as s { let c = move s; " + "release c; } release b; }", ["OWN034"]), + ("shadow_borrow_binding", + "fn f(){ let b = acquire Buffer(1); borrow b as s { borrow b as s " + "{ use s; } } release b; }", ["OWN031"]), + ("return_moved_and_leak", + "fn f() -> Buffer { let a = acquire Buffer(1); let b = move a; " + "return a; }", ["OWN001", "OWN005"]), + ("plain_copy_to_resource_param", + "fn f(){ let a = acquire Buffer(1); let b = a; Hash(b); release a; }", + ["OWN032", "OWN041"]), + # ---- buffer storage policies: clean ---- ("buf_scratch_ok", "fn f(n: int){ let b = Buffer.scratch(n, inline = 1024); " From 728e86852449bf4f9b3ec184315f8c901ad887ff Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 14:54:38 +0000 Subject: [PATCH 3/3] analysis: OWN035 checks the returned resource TYPE, not just ownership Follow-up to the OWN035 return-type check: it only verified that a value returned from a resource-typed function was *owned*, so a different owned resource still slipped through -- `fn f() -> Buffer { let c = acquire Conn(1); return c; }` produced no diagnostic and codegen emitted a `Buffer`-returning method handing back a Conn, recreating the analyzer/codegen mismatch this check exists to close. Thread the resource type onto owned symbols (set on `acquire`, propagated through `move`; params already carried it) and compare it to the declared return type. Buffers keep their own escape rules (OWN015/016/017), so they are left to the analyzer rather than reported here. A wrong-typed owned resource is kept as escaped (not nulled) so it is not also spuriously reported as a leak -- the type mismatch is the single error. Tests: wrong-resource return (acquired + param), plus a clean `-> Conn` return. Analysis suite 123/123. --- ownlang/cfg.py | 27 ++++++++++++++++++++------- tests/run_tests.py | 6 ++++++ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/ownlang/cfg.py b/ownlang/cfg.py index b2d1c6c9..4bf92f21 100644 --- a/ownlang/cfg.py +++ b/ownlang/cfg.py @@ -331,6 +331,7 @@ def lower_let(self, st: A.Let, cur: Block) -> Block: self.diags.append(Diagnostic( "OWN030", f"undefined resource '{rhs.resource}'", rhs.line)) sym = self.declare(st.name, Kind.OWNED, st.line) + sym.type_name = rhs.resource cur.instrs.append(Acquire(sym, rhs.resource, st.line)) return cur if isinstance(rhs, A.BufferIntent): @@ -350,6 +351,7 @@ def lower_let(self, st: A.Let, cur: Block) -> Block: # original buffer in the report. dst.buffer = src.buffer dst.origin = src.origin + dst.type_name = src.type_name # the moved value keeps its type cur.instrs.append(MoveInto(dst, src, st.line)) return cur if isinstance(rhs, A.VarRef): @@ -521,16 +523,27 @@ def lower_return(self, st: A.Return, cur: Block) -> Block | None: f"outlive the resource it borrows)", st.line)) sym = None elif (not ret.borrowed and ret.name in self.resource_names - and sym is not None and sym.kind != Kind.OWNED): - # the return type is an owned resource, but the returned value is - # not one (e.g. `return n;` where n is a plain int). Dropping the - # symbol silently is what let codegen emit an uncompilable - # `return n;` from a `-> Buffer` method. + and sym is not None and sym.buffer is None + and (sym.kind != Kind.OWNED or sym.type_name != ret.name)): + # the return type is an owned resource; the returned value must be + # an owned resource OF THE SAME type. Both a plain (`return n;`) + # and a different resource (`return c;` where c is a Conn but the + # function returns Buffer) lower to an uncompilable method, and + # the analyzer would otherwise pass them. (Buffers have their own + # escape rules -- OWN015/016/017 -- so they are left to the + # analyzer rather than reported here.) + if sym.kind != Kind.OWNED: + what = "not an owned resource" + sym = None # a plain value: nothing escapes + else: + # a real (but wrong-typed) owned resource still leaves the + # function; keep it so it is marked escaped, not leaked -- + # the type mismatch is the error, an extra OWN001 is noise. + what = f"an owned '{sym.type_name}'" self.diags.append(Diagnostic( "OWN035", f"'{self.fn.name}' returns '{ret.name}' but '{st.var}' is " - f"not an owned resource", st.line)) - sym = None + f"{what}", st.line)) elif sym is not None and sym.kind == Kind.PLAIN: sym = None cur.instrs.append(Return(sym, st.line)) diff --git a/tests/run_tests.py b/tests/run_tests.py index c77330d3..59e6ed01 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -143,6 +143,12 @@ def codes(src: str) -> list[str]: "fn f() -> Buffer { return; }", ["OWN035"]), ("return_value_from_void", "fn g(n: int){ return n; }", ["OWN035"]), + ("return_wrong_resource_type", + "fn f() -> Buffer { let c = acquire Conn(1); return c; }", ["OWN035"]), + ("return_wrong_resource_param", + "fn f(c: Conn) -> Buffer { return c; }", ["OWN035"]), + ("ok_return_owned_conn", + "fn f() -> Conn { let c = acquire Conn(1); return c; }", []), ("ok_bare_return_void", "fn f(){ let b = acquire Buffer(1); release b; return; }", []), ("loop_rejected", "fn f(){ while (x) { use x; } }", ["OWN020"]),