Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
57ae2ff
Add buffer storage policies with mandatory logging
claude Jun 13, 2026
e5d5254
Fix buffer escape cleanup and forbidden-fallback scratch (PR #2 review)
claude Jun 13, 2026
541dbbb
Reject escaping movable buffers; add runnable ArrayPool golden (PR #2)
claude Jun 13, 2026
6a94513
Handle branchy buffer release; don't crash report on bad mode (PR #2)
claude Jun 13, 2026
4ae71b8
Handle overlapping buffer lifetimes; validate fallback; report OWN017
claude Jun 14, 2026
70652fb
Track buffer move-aliases; require the Buffer namespace (PR #2)
claude Jun 14, 2026
d9254b7
Reject non-identifier scratch fallback values (PR #2)
claude Jun 14, 2026
3ee9c74
Nest ordinary resources with buffers; guard native size; honor policy…
claude Jun 14, 2026
996e730
Fix buffer move in sibling branches, scratch-only counters, simple-mo…
claude Jun 14, 2026
ce7db10
Split simple-mode scopes at release; guard scratch size; reject bad p…
claude Jun 14, 2026
777428a
Scope buffer cleanup aliases; reject non-integer bounds; refresh golden
claude Jun 14, 2026
1c63f3a
Validate only the effective inline source (override beats policy defa…
claude Jun 14, 2026
df63790
Restrict hoist to laminar lifetimes; expose native buffers as Span<byte>
claude Jun 14, 2026
19ca1df
Reject unknown buffer option and policy setting names
claude Jun 14, 2026
dc1f984
Require integer buffer sizes; reject dynamic inline buffers
claude Jun 14, 2026
51c11c9
Reject malformed boolean/trace policy and option values
claude Jun 14, 2026
3d9097e
Do not hoist resources whose release is nested or consumed
claude Jun 14, 2026
6ad85bd
Span signatures for local Buffer helpers; attribute report by buffer …
claude Jun 14, 2026
ccb9cb5
Reject duplicate buffer options and policy settings
claude Jun 14, 2026
bb064f8
Reject unknown-type sizes; keep locals declared before a hoisted rele…
claude Jun 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
*.ownreport.json
201 changes: 194 additions & 7 deletions README.md

Large diffs are not rendered by default.

50 changes: 43 additions & 7 deletions __main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
python -m ownlang check file.own # report ownership diagnostics
python -m ownlang emit file.own # check, then print generated C#
python -m ownlang cfg file.own # dump the control-flow graph
python -m ownlang report file.own # buffer storage report + .ownreport.json

Exit code is non-zero if any error-level diagnostic was produced.
"""
Expand All @@ -14,9 +15,11 @@

from .parser import parse, ParseError
from .lexer import LexError
from .cfg import build_cfg, collect_signatures, CFG
from .cfg import build_cfg, collect_signatures, collect_policies, CFG
from .analysis import analyze
from .codegen import generate
from .buffers import validate_policies
from .report import build_report, render_report
from .diagnostics import Diagnostic, Severity


Expand All @@ -28,9 +31,10 @@ def _collect(src: str) -> tuple[list[Diagnostic], object | None]:
return [Diagnostic("OWN020", str(e).split(": ", 1)[-1], line)], None
rnames = {r.name for r in mod.resources}
sigs = collect_signatures(mod)
diags: list[Diagnostic] = []
pols = collect_policies(mod)
diags: list[Diagnostic] = list(validate_policies(pols))
for fn in mod.functions:
cfg, d1 = build_cfg(fn, rnames, sigs)
cfg, d1 = build_cfg(fn, rnames, sigs, pols)
d2 = analyze(cfg)
diags.extend(d1)
diags.extend(d2)
Expand Down Expand Up @@ -73,12 +77,38 @@ def cmd_cfg(path: str) -> int:
return 1
rnames = {r.name for r in mod.resources}
sigs = collect_signatures(mod)
pols = collect_policies(mod)
for fn in mod.functions:
cfg, _ = build_cfg(fn, rnames, sigs)
cfg, _ = build_cfg(fn, rnames, sigs, pols)
_print_cfg(cfg)
return 0


def cmd_report(path: str) -> int:
"""Emit the compile-time buffer report: what storage policy the checker and
codegen settled on for every buffer, and which checks passed. Prints a
human summary to stdout and writes the machine-readable .ownreport.json."""
src = _read(path)
diags, mod = _collect(src)
if mod is None:
for d in diags:
print(d.render(path), file=sys.stderr)
return 1
# surface diagnostics (e.g. a mistyped buffer mode) without crashing the
# report; the report still covers every well-formed buffer.
errors = [d for d in diags if d.severity == Severity.ERROR]
for d in diags:
print(d.render(path), file=sys.stderr)
report = build_report(mod, diags) # type: ignore[arg-type]
print(render_report(report))
out_path = path.rsplit(".", 1)[0] + ".ownreport.json"
import json
with open(out_path, "w", encoding="utf-8") as f:
json.dump(report, f, indent=2)
print(f"\nwrote {out_path}")
return 1 if errors else 0


def _print_cfg(cfg: CFG) -> None:
print(f"fn {cfg.fn_name} (entry: B{cfg.entry}, "
f"params: {[p.name for p in cfg.params]})")
Expand All @@ -91,10 +121,15 @@ def _print_cfg(cfg: CFG) -> None:


def _fmt_instr(ins) -> str:
from .cfg import (Acquire, MoveInto, Release, Use, Invoke,
from .cfg import (Acquire, AcquireBuffer, MoveInto, Release, Use, Invoke,
BorrowStart, BorrowEnd, Return)
if isinstance(ins, Acquire):
return f"acquire {ins.sym.name} : {ins.resource}"
if isinstance(ins, AcquireBuffer):
i = ins.info
size = i.size_const if i.size_is_const else (i.size_var or "?")
return (f"buffer {ins.sym.name} : {i.mode.value}(size={size}, "
f"inline={i.inline_bytes}, fallback={'pool' if i.fallback_pool else 'none'})")
if isinstance(ins, MoveInto):
return f"move {ins.src.name} -> {ins.dst.name}"
if isinstance(ins, Release):
Expand Down Expand Up @@ -122,11 +157,12 @@ def _read(path: str) -> str:


def main(argv: list[str]) -> int:
if len(argv) < 2 or argv[0] not in {"check", "emit", "cfg"}:
if len(argv) < 2 or argv[0] not in {"check", "emit", "cfg", "report"}:
print(__doc__)
return 2
cmd, path = argv[0], argv[1]
return {"check": cmd_check, "emit": cmd_emit, "cfg": cmd_cfg}[cmd](path)
return {"check": cmd_check, "emit": cmd_emit, "cfg": cmd_cfg,
"report": cmd_report}[cmd](path)


if __name__ == "__main__":
Expand Down
66 changes: 52 additions & 14 deletions analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@

from .cfg import (
CFG, Block, Symbol, Kind,
Acquire, MoveInto, Release, Use, Invoke, BorrowStart, BorrowEnd, Return,
Acquire, AcquireBuffer, MoveInto, Release, Use, Invoke,
BorrowStart, BorrowEnd, Return,
)
from .ast_nodes import Effect
from .diagnostics import Diagnostic
Expand Down Expand Up @@ -103,8 +104,9 @@ def initial_state(self) -> State:
s.var[id(p)] = {VarState.OWNED}
return s

def err(self, code: str, msg: str, line: int) -> None:
self.diags.append(Diagnostic(code, msg, line))
def err(self, code: str, msg: str, line: int,
subject: str | None = None) -> None:
self.diags.append(Diagnostic(code, msg, line, subject=subject))

# -- loan / permission helpers -----------------------------------------

Expand All @@ -129,24 +131,28 @@ def binding_live(self, st: State, sym: Symbol) -> bool:
# gone / maybe-gone cases shared by use/move/release/borrow/consume.
def _state_problem(self, st: State, sym: Symbol, verb: str, line: int) -> bool:
S = st.var.get(id(sym), {VarState.OWNED})
subj = sym.origin
if VarState.OWNED not in S:
if VarState.MOVED in S:
self.err("OWN005", f"{verb} '{sym.name}' after it was moved", line)
self.err("OWN005", f"{verb} '{sym.name}' after it was moved",
line, subject=subj)
elif VarState.ESCAPED in S and VarState.RELEASED not in S:
self.err("OWN002",
f"{verb} '{sym.name}' after it was consumed", line)
f"{verb} '{sym.name}' after it was consumed", line,
subject=subj)
else:
self.err("OWN002", f"{verb} '{sym.name}' after it was released", line)
self.err("OWN002", f"{verb} '{sym.name}' after it was released",
line, subject=subj)
return True
if S & {VarState.RELEASED, VarState.ESCAPED}:
self.err("OWN009",
f"{verb} '{sym.name}', which may have been released on some "
f"path", line)
f"path", line, subject=subj)
return True
if VarState.MOVED in S:
self.err("OWN010",
f"{verb} '{sym.name}', which may have been moved on some "
f"path", line)
f"path", line, subject=subj)
return True
return False

Expand Down Expand Up @@ -230,7 +236,8 @@ def leak_check(self, st: State, at_line: int, context: str,
name = sym.name if sym else f"#{symid}"
self.err("OWN001",
f"'{name}' is owned but not released {context} "
f"(leaks on at least one path)", at_line)
f"(leaks on at least one path)", at_line,
subject=(sym.origin if sym else None))

def _sym_by_id(self, symid: int) -> Symbol | None:
if not hasattr(self, "_symindex"):
Expand Down Expand Up @@ -263,26 +270,32 @@ def step(self, ins, st: State) -> None:
st.var[id(ins.sym)] = {VarState.OWNED}
return

if isinstance(ins, AcquireBuffer):
st.var[id(ins.sym)] = {VarState.OWNED}
return

if isinstance(ins, MoveInto):
self._consume_like(st, ins.src, "move", ins.line, code_borrowed="OWN007")
st.var[id(ins.src)] = {VarState.MOVED}
st.var[id(ins.dst)] = {VarState.OWNED}
return

if isinstance(ins, Release):
subj = ins.sym.origin
S = st.var.get(id(ins.sym), {VarState.OWNED})
if S == {VarState.RELEASED}:
self.err("OWN003", f"'{ins.sym.name}' is released twice", ins.line)
self.err("OWN003", f"'{ins.sym.name}' is released twice",
ins.line, subject=subj)
elif VarState.RELEASED in S:
self.err("OWN003",
f"'{ins.sym.name}' may already be released on some path "
f"before this release", ins.line)
f"before this release", ins.line, subject=subj)
elif not self._state_problem(st, ins.sym, "release", ins.line):
shared, mut = self.loans_on(st, ins.sym)
if shared or mut:
self.err("OWN008",
f"cannot release '{ins.sym.name}' while it is borrowed",
ins.line)
ins.line, subject=subj)
st.var[id(ins.sym)] = {VarState.RELEASED}
return

Expand Down Expand Up @@ -327,16 +340,29 @@ def step(self, ins, st: State) -> None:
self.leak_check(st, at_line=ins.line, context="before return",
exclude=ins.sym)
if ins.sym is not None:
subj = ins.sym.origin
S = st.var.get(id(ins.sym), {VarState.OWNED})
if VarState.OWNED not in S:
if VarState.MOVED in S:
self.err("OWN005",
f"'{ins.sym.name}' returned after it was moved",
ins.line)
ins.line, subject=subj)
else:
self.err("OWN002",
f"'{ins.sym.name}' returned after it was released",
ins.line)
ins.line, subject=subj)
elif ins.sym.buffer is not None and ins.sym.buffer.stack_backed:
self.err("OWN015",
f"'{ins.sym.name}' is a {ins.sym.buffer.mode.value} "
f"buffer and may be stack-backed; it cannot escape "
f"the current function", ins.line, subject=subj)
elif ins.sym.buffer is not None:
self.err("OWN017",
f"'{ins.sym.name}' is a {ins.sym.buffer.mode.value} "
f"buffer; the PoC code generator cannot lower an "
f"escaping buffer to faithful .NET (the caller gets "
f"no handle to Return/Free), so returning it is "
f"rejected", ins.line, subject=subj)
st.var[id(ins.sym)] = {VarState.ESCAPED}
return

Expand Down Expand Up @@ -381,6 +407,18 @@ def _apply_effect(self, st: State, sym: Symbol, eff: Effect,
if sym.kind == Kind.OWNED:
if eff == Effect.CONSUME:
self._consume_like(st, sym, "consume", line, code_borrowed="OWN007")
if sym.buffer is not None and sym.buffer.stack_backed:
self.err("OWN016",
f"'{sym.name}' is a {sym.buffer.mode.value} buffer "
f"and may be stack-backed; it cannot be moved to a "
f"longer-lived owner by consuming it in '{callee}'",
line, subject=sym.origin)
elif sym.buffer is not None:
self.err("OWN017",
f"'{sym.name}' is a {sym.buffer.mode.value} buffer; "
f"the PoC code generator cannot lower an escaping "
f"buffer, so consuming it in '{callee}' is rejected",
line, subject=sym.origin)
st.var[id(sym)] = {VarState.ESCAPED}
elif eff == Effect.BORROW_MUT:
self._check_mut_borrowable(st, sym, line)
Expand Down
29 changes: 28 additions & 1 deletion ast_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,23 @@ class Move:
line: int


Expr = IntLit | VarRef | Acquire | Move
@dataclass
class BufferIntent:
"""Buffer.<mode>(size, name = value, ...) -> Owned<Buffer> with a storage
policy. `mode` is one of stack/scratch/pooled/native/inline. `size` is the
single positional argument (an IntLit or VarRef), or None. `options` maps a
named option (inline, max, fallback, clear, trace, counters, policy) to its
value expression. `ns` is the namespace as written (must be "Buffer")."""
mode: str
size: "Expr | None"
options: dict[str, "Expr"]
line: int
ns: str = "Buffer"
col: int = 0
dups: tuple = () # option names that appeared more than once


Expr = IntLit | VarRef | Acquire | Move | BufferIntent


# ---- statements -----------------------------------------------------------
Expand Down Expand Up @@ -177,9 +193,20 @@ class FnDecl:
line: int


@dataclass
class PolicyDecl:
"""policy Name { key = value; ... } — a named bundle of buffer defaults
(inline_bytes, max_bytes, mode, fallback, trace, counters, clear_on_release)."""
name: str
settings: dict[str, object]
line: int
dups: tuple = () # setting keys that appeared more than once


@dataclass
class Module:
name: str
resources: list[ResourceDecl] = field(default_factory=list)
externs: list[ExternDecl] = field(default_factory=list)
functions: list[FnDecl] = field(default_factory=list)
policies: list[PolicyDecl] = field(default_factory=list)
10 changes: 10 additions & 0 deletions bad_scratch_escape.own.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
module BadScratchEscape

// OWN015: a scratch buffer may be stack-backed, so returning it would hand back
// a span into a frame that is about to pop. If you need to return a buffer, use
// Buffer.pooled(...) — a movable owned resource whose Return the checker enforces.

fn leak(size: int) -> Buffer {
let tmp = Buffer.scratch(size, inline = 1024);
return tmp;
}
32 changes: 32 additions & 0 deletions buffer_scratch.own.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module ScratchDemo

// A scratch buffer: prefer the stack, fall back to ArrayPool when the request
// exceeds the inline limit. The user states the intent; the checker proves it
// stays local; codegen emits both branches; and OwnTrace/OwnCounters log which
// backend was actually chosen at runtime.
//
// python -m ownlang check buffer_scratch.own # ownership is clean
// python -m ownlang emit buffer_scratch.own # stack-first + pool fallback
// python -m ownlang report buffer_scratch.own # what the compiler decided

policy DefaultScratch {
inline_bytes = 1024;
fallback = pool;
trace = debug;
counters = true;
clear_on_release = false;
}

extern fn Fill(borrow_mut Buffer);
extern fn Hash(borrow Buffer);

fn parse(size: int) {
let tmp = Buffer.scratch(size, inline = 1024, fallback = pool);
borrow_mut tmp as bytes {
Fill(bytes);
}
borrow tmp as view {
Hash(view);
}
release tmp;
}
Loading