diff --git a/ownlang/__main__.py b/ownlang/__main__.py
index a93c3da9..900d6d04 100644
--- a/ownlang/__main__.py
+++ b/ownlang/__main__.py
@@ -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
@@ -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)
@@ -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
@@ -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"}
@@ -435,15 +445,17 @@ 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`",
@@ -451,10 +463,16 @@ def main(argv: list[str]) -> int:
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__":
diff --git a/ownlang/cfg_json.py b/ownlang/cfg_json.py
new file mode 100644
index 00000000..f27fc39a
--- /dev/null
+++ b/ownlang/cfg_json.py
@@ -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)
diff --git a/rust/.gitignore b/rust/.gitignore
new file mode 100644
index 00000000..2f7896d1
--- /dev/null
+++ b/rust/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/rust/Cargo.lock b/rust/Cargo.lock
new file mode 100644
index 00000000..c23ddd21
--- /dev/null
+++ b/rust/Cargo.lock
@@ -0,0 +1,107 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "own-ir"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/rust/Cargo.toml b/rust/Cargo.toml
new file mode 100644
index 00000000..efad5ceb
--- /dev/null
+++ b/rust/Cargo.toml
@@ -0,0 +1,48 @@
+# The Rust core workspace (P-022). The crate graph IS the architecture: the
+# allowed dependency edges are documented in docs/proposals/P-022-rust-core-migration.md
+# and will be locked by a `cargo metadata` fitness test as crates are added.
+#
+# Population order follows the migration plan (strangler-fig, oracle-gated):
+# own-ir (here) -> own-syntax -> own-cfg -> own-analysis -> own-diagnostics ->
+# own-codegen -> own-bridge -> own-cli. Python stays authoritative until parity.
+
+[workspace]
+resolver = "2"
+members = ["crates/own-ir"]
+
+[workspace.package]
+edition = "2021"
+rust-version = "1.74" # floor for declarative [workspace.lints]
+license = "MIT"
+publish = false
+
+[workspace.dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+# Strictness per P-022 §"Compiler strictness" — inherited by every crate via
+# `[lints] workspace = true`. pedantic/nursery stay WARN (surgical, justified
+# allows only); the restriction lints are denied surgically.
+[workspace.lints.rust]
+unsafe_code = "forbid" # forbid where unsafe isn't needed — cannot be overridden
+unreachable_pub = "deny" # a pub nobody sees is a lie in the API
+missing_debug_implementations = "warn"
+rust_2018_idioms = { level = "deny", priority = -1 }
+
+[workspace.lints.clippy]
+pedantic = { level = "warn", priority = -1 }
+nursery = { level = "warn", priority = -1 }
+unwrap_used = "deny"
+expect_used = "warn"
+indexing_slicing = "deny"
+arithmetic_side_effects = "deny"
+panic = "deny"
+dbg_macro = "deny"
+print_stdout = "deny"
+
+# Release profile per P-022 — panic is per-binary, NOT set here: "abort" for
+# own-cli, "unwind" for any LSP binary (salsa cancels via unwinding).
+[profile.release]
+lto = "thin"
+codegen-units = 1
+opt-level = 3
diff --git a/rust/crates/own-ir/Cargo.toml b/rust/crates/own-ir/Cargo.toml
new file mode 100644
index 00000000..1a7c8d09
--- /dev/null
+++ b/rust/crates/own-ir/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "own-ir"
+description = "OwnIR fact contract (serde types + schema-version gate) and the span/location leaf"
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+publish.workspace = true
+version = "0.1.0"
+
+[dependencies]
+serde = { workspace = true }
+serde_json = { workspace = true }
+
+[lints]
+workspace = true
diff --git a/rust/crates/own-ir/src/lib.rs b/rust/crates/own-ir/src/lib.rs
new file mode 100644
index 00000000..c97f3526
--- /dev/null
+++ b/rust/crates/own-ir/src/lib.rs
@@ -0,0 +1,426 @@
+//! `own-ir` — the `OwnIR` **fact** contract, re-typed with serde (P-022 step 1).
+//!
+//! `OwnIR` is the frozen seam between the frontends (the Roslyn C# extractor,
+//! `OwnTS`) and the core: a versioned JSON fact vocabulary. This crate is the
+//! Rust side of that seam. Its acceptance rule mirrors the Python reference
+//! (`ownlang/ownir.py::load`) exactly:
+//!
+//! * **typed fields are only the ones Python validates** — everything else
+//! rides in a flattened `extra` map, so additive optional fields a newer
+//! frontend emits are tolerated *and preserved on round-trip* (the parity
+//! property `tests/roundtrip.rs` pins against the repo's `OwnIR` fixtures);
+//! * the **schema version gates first** (`ownir_version`, absent ⇒ v0), and a
+//! vocabulary mismatch fails loudly with an actionable message;
+//! * JSON `true` is **not** an integer here (unlike Python, where `bool` is an
+//! `int` subclass and needs an explicit check — Rust gets that for free).
+//!
+//! Verdict types deliberately do **not** live here: `own-ir` is facts + the
+//! span/location leaf; diagnostics/evidence belong to `own-diagnostics`.
+//!
+//! Error *message* parity with Python is not claimed yet — that lands with the
+//! shared error-text fixtures (P-022 oracle section), not by copy-paste.
+
+pub mod span;
+
+use serde::{Deserialize, Serialize};
+use serde_json::{Map, Value};
+
+/// The schema version this crate understands. Bump only on an incompatible
+/// vocabulary change — additive optional fields are NOT a version bump.
+pub const OWNIR_VERSION: i64 = 0;
+
+/// A shape/vocabulary violation in an `OwnIR` document. Facts are external
+/// input, so a malformed file must fail with a clear error, not a panic.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct OwnIrError(pub String);
+
+impl std::fmt::Display for OwnIrError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+impl std::error::Error for OwnIrError {}
+
+/// Deserializer for load()-validated optional fields: **absent** means default
+/// (Python's `d.get("f", default)`), but a **present `null` is rejected** —
+/// exactly like Python's `isinstance` check failing on `None`. `serde(default)`
+/// handles absence before this runs; here a null hits `T::deserialize` and
+/// errors.
+fn reject_null<'de, D, T>(de: D) -> Result