Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
40 changes: 29 additions & 11 deletions ownlang/__main__.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,8 @@
python -m ownlang check file.own # report ownership diagnostics
python -m ownlang check file.own --format sarif # SARIF 2.1.0 log (code scanning)
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 cfg file.own # dump the control-flow graph (human debug view)
python -m ownlang cfg file.own --format json # canonical CFG JSON (oracle seam)
python -m ownlang report file.own # buffer storage report + .ownreport.json
python -m ownlang ownir facts.json # check OwnIR facts extracted from C# (P-001)
python -m ownlang ownir facts.json --format github|msbuild|human|sarif
Expand DownExpand Up@@ -117,7 +118,7 @@ def cmd_emit(path: str) -> int:
return 0


def cmd_cfg(path: str) -> int:
def cmd_cfg(path: str, fmt: str = "human") -> int:
src = _read(path)
try:
mod = parse(src)
Expand All@@ -127,8 +128,17 @@ def cmd_cfg(path: str) -> int:
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, pols)
kinds = collect_kinds(mod)
cfgs = [build_cfg(fn, rnames, sigs, pols, kinds)[0] for fn in mod.functions]
if fmt == "json":
# The canonical CFG-layer oracle seam (P-022 step 0): a frozen,
# deterministic JSON contract the Rust port is diffed against. The
# human dump below stays a debug view, not a contract. Canonical text
# (sorted keys) is the contract's own dump, not an ad-hoc json.dumps.
from .cfg_json import canonical_json
print(canonical_json(cfgs))
return 0
for cfg in cfgs:
_print_cfg(cfg)
return 0

Expand DownExpand Up@@ -353,7 +363,7 @@ def cmd_ownir(path: str, fmt: str = "human", severity: str = "error",
return 1 if leaks else 0


_FORMATS = {"human", "github", "msbuild", "sarif"}
_FORMATS = {"human", "github", "msbuild", "sarif", "json"}
_SEVERITIES = {"error", "warning"}
_VERBOSITY = {"quiet", "normal", "verbose"}

Expand DownExpand Up@@ -435,26 +445,34 @@ def main(argv: list[str]) -> int:
# Value-flag scope, rejected by *presence* (so a redundant `--format human` is a
# clear error, not a silent no-op): `ownir` takes all three; `check` takes only
# `--format`, and only human|sarif (github/msbuild are per-finding renderers that
# need an OwnIR Finding, not a Diagnostic); every other command takes none.
if cmd == "check":
# need an OwnIR Finding, not a Diagnostic); `cfg` takes only `--format`, and only
# human|json (the canonical CFG-layer oracle seam); every other command takes none.
if cmd in {"check", "cfg"}:
extra = seen - {"--format"}
if extra:
print(f"{'/'.join(sorted(extra))} only apply to `ownir`", file=sys.stderr)
return 2
if fmt not in {"human", "sarif"}:
print(f"check --format must be 'human' or 'sarif' (got {fmt!r})",
file=sys.stderr)
allowed = {"human", "sarif"} if cmd == "check" else {"human", "json"}
if fmt not in allowed:
print(f"{cmd} --format must be one of {'/'.join(sorted(allowed))} "
f"(got {fmt!r})", file=sys.stderr)
return 2
elif cmd != "ownir" and seen:
print("--format/--severity/--verbosity only apply to `ownir`",
file=sys.stderr)
return 2
path = positional[0]
if cmd == "ownir":
if fmt == "json": # json is the cfg seam's format, not an ownir surface
print("ownir --format must be one of github/human/msbuild/sarif "
"(got 'json')", file=sys.stderr)
return 2
return cmd_ownir(path, fmt, severity, verbosity)
if cmd == "check":
return cmd_check(path, fmt, severity)
return {"emit": cmd_emit, "cfg": cmd_cfg, "report": cmd_report}[cmd](path)
if cmd == "cfg":
return cmd_cfg(path, fmt)
return {"emit": cmd_emit, "report": cmd_report}[cmd](path)


if __name__ == "__main__":
Expand Down
186 changes: 186 additions & 0 deletions ownlang/cfg_json.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
"""Canonical CFG JSON export — the frozen CFG-layer oracle seam (P-022 step 0).

`python -m ownlang cfg file.own` prints a *human* dump (`_print_cfg`), which is a
debug format, not a contract. The Rust-migration differential oracle needs a
CFG-layer seam it can diff exactly, so this module projects a lowered `CFG` into
a **canonical, deterministic JSON shape** that both implementations can emit:

* blocks in id order, fields in a fixed vocabulary, no volatile values;
* every `Symbol` reference is an **index into a per-function symbol table**
(first-appearance order: params, then instruction operands). Python's
in-memory symbol identity is `id(sym)` — meaningless across processes — but
the *identity structure* (two same-named symbols in sibling scopes are
distinct; a moved alias shares nothing with its source) is exactly what a
port must reproduce, and indices express it portably;
* the shape is versioned (`ownlang_cfg_version`) like OwnIR: additive optional
fields are tolerated, vocabulary changes must fail loudly.

Pure projection: no analysis, no mutation, dependency-free beyond the CFG/buffer
types it reads (mirrors `evidence.py` / `diag_sarif.py`).
"""

from __future__ import annotations

from typing import Any

from .buffers import BufferInfo
from .cfg import (
CFG,
Acquire,
AcquireBuffer,
AliasJoin,
BorrowEnd,
BorrowStart,
Instr,
Invoke,
MoveInto,
Overspan,
Release,
Return,
Symbol,
Use,
)

# Version gate for the seam itself, independent of OwnIR's: bump on any
# incompatible vocabulary change, never for additive optional fields.
CFG_JSON_VERSION = 0


def _buffer_json(info: BufferInfo | None) -> dict[str, Any] | None:
if info is None:
return None
return {
"mode": info.mode.value,
"elem": info.elem,
"size_const": info.size_const,
"size_var": info.size_var,
"inline_bytes": info.inline_bytes,
"fallback_pool": info.fallback_pool,
"fallback_forbidden": info.fallback_forbidden,
"clear_on_release": info.clear_on_release,
"sensitive": info.sensitive,
"trace": info.trace,
"counters": info.counters,
"policy_name": info.policy_name,
"line": info.line,
}


class _SymTable:
"""Symbol -> stable index, in first-appearance order. Keyed by object
identity (the same identity the analysis keys on), so aliasing structure
survives the projection even between same-named symbols."""

def __init__(self) -> None:
self._index: dict[int, int] = {}
self.rows: list[dict[str, Any]] = []

def ref(self, sym: Symbol | None) -> int | None:
if sym is None:
return None
got = self._index.get(id(sym))
if got is not None:
return got
idx = len(self.rows)
self._index[id(sym)] = idx
self.rows.append({
"name": sym.name,
"kind": sym.kind.name.lower(),
"def_line": sym.def_line,
"is_param_borrow": sym.is_param_borrow,
"borrow_is_mut": sym.borrow_is_mut,
"type_name": sym.type_name,
"resource_kind": sym.resource_kind,
"origin": sym.origin,
"buffer": _buffer_json(sym.buffer),
})
return idx


def _instr_json(ins: Instr, syms: _SymTable) -> dict[str, Any]:
"""One instruction as {op, ...fields, line}. The op vocabulary is part of
the frozen contract; adding a CFG instruction means a new op string AND a
version review, exactly like an OwnIR vocabulary change."""
if isinstance(ins, Acquire):
return {"op": "acquire", "sym": syms.ref(ins.sym),
"resource": ins.resource, "line": ins.line}
if isinstance(ins, AcquireBuffer):
return {"op": "acquire_buffer", "sym": syms.ref(ins.sym),
"buffer": _buffer_json(ins.info), "line": ins.line}
if isinstance(ins, MoveInto):
return {"op": "move_into", "dst": syms.ref(ins.dst),
"src": syms.ref(ins.src), "line": ins.line}
if isinstance(ins, Release):
return {"op": "release", "sym": syms.ref(ins.sym), "line": ins.line}
if isinstance(ins, Use):
return {"op": "use", "sym": syms.ref(ins.sym), "line": ins.line}
if isinstance(ins, Overspan):
return {"op": "overspan", "sym": syms.ref(ins.sym), "line": ins.line}
if isinstance(ins, Invoke):
return {"op": "invoke", "callee": ins.callee,
"args": [{"sym": syms.ref(s), "effect": e.name.lower()}
for s, e in ins.args],
"line": ins.line}
if isinstance(ins, BorrowStart):
return {"op": "borrow_start", "owner": syms.ref(ins.owner),
"binding": syms.ref(ins.binding), "mut": ins.mut,
"line": ins.line}
if isinstance(ins, BorrowEnd):
return {"op": "borrow_end", "owner": syms.ref(ins.owner),
"binding": syms.ref(ins.binding), "mut": ins.mut,
"line": ins.line}
if isinstance(ins, AliasJoin):
return {"op": "alias_join", "handle": syms.ref(ins.handle),
"src": syms.ref(ins.src), "line": ins.line}
# Return is the last variant; keeping the explicit check (rather than a bare
# else) preserves the exhaustiveness shape of the analysis dispatchers.
if isinstance(ins, Return):
return {"op": "return", "sym": syms.ref(ins.sym), "line": ins.line}
raise AssertionError(f"unhandled CFG instruction: {ins!r}")


def cfg_json(cfg: CFG) -> dict[str, Any]:
"""One function's CFG as a canonical JSON object. Deterministic: blocks in
id order, symbols in first-appearance order, no volatile fields."""
syms = _SymTable()
params = [syms.ref(p) for p in cfg.params]
blocks = [
{
"id": b.id,
"label": b.label,
"succ": list(b.succ),
"instrs": [_instr_json(i, syms) for i in b.instrs],
}
for b in sorted(cfg.blocks, key=lambda b: b.id)
]
return {
"name": cfg.fn_name,
"entry": cfg.entry,
"has_return_type": cfg.has_return_type,
"params": params,
"symbols": syms.rows,
"blocks": blocks,
}


def module_cfg_json(cfgs: list[CFG]) -> dict[str, Any]:
"""The whole module's CFGs as one versioned document — the unit the oracle
diffs at the CFG layer.

NOTE: the returned dict is deterministic in *content*, but canonical **text**
requires ``sort_keys=True`` at dump time — use :func:`canonical_json` rather
than calling ``json.dumps`` yourself, or the seam's byte-identity property
silently degrades to value-identity."""
return {
"ownlang_cfg_version": CFG_JSON_VERSION,
"functions": [cfg_json(c) for c in cfgs],
}


def canonical_json(cfgs: list[CFG]) -> str:
"""The canonical textual form of the seam — what `cfg --format json` prints
and what the oracle byte-compares. Canonicalization (sorted keys, fixed
indent) lives HERE, with the contract, not at call sites."""
import json

return json.dumps(module_cfg_json(cfgs), indent=2, sort_keys=True)
1 change: 1 addition & 0 deletions rust/.gitignore
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
target/
107 changes: 107 additions & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading