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, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + T::deserialize(de).map(Some) +} + +/// Deserializer for the three fields Python checks with `if x is not None and +/// not isinstance(...)` — a present `null` is **accepted** there, and the value +/// stays `null` in the document, so round-trip must preserve it. Outer `None` = +/// absent (skipped on serialize); `Some(None)` = explicit null (serialized as +/// `null`); `Some(Some(v))` = a value. +#[allow(clippy::option_option)] // the 3 states ARE the contract: absent / explicit null / value +fn nullable<'de, D, T>(de: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(de).map(Some) +} + +/// DI registration lifetime — the only closed vocabulary inside the facts +/// (`ownlang/di.py::LIFETIMES`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Lifetime { + Singleton, + Scoped, + Transient, +} + +/// Ownership effect a function parameter applies to its argument — the same +/// closed set `load()` enforces on `functions[].params[].effect`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ParamEffect { + Consume, + Borrow, + BorrowMut, + Plain, +} + +/// One `{type, file, line}` call-site record (DI004 / DI005 metadata). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Site { + #[serde( + rename = "type", + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub type_name: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub file: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub line: Option, + #[serde(flatten)] + pub extra: Map, +} + +/// One event subscription inside a component. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Subscription { + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub resource: Option, + #[serde( + rename = "type", + default, + deserialize_with = "nullable", + skip_serializing_if = "Option::is_none" + )] + pub type_name: Option>, + #[serde( + default, + deserialize_with = "nullable", + skip_serializing_if = "Option::is_none" + )] + pub source_type: Option>, + #[serde(flatten)] + pub extra: Map, +} + +/// One component (a view model / window / control the extractor saw). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Component { + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub subscriptions: Option>, + #[serde(flatten)] + pub extra: Map, +} + +/// One DI service registration (P-006). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Service { + pub lifetime: Lifetime, + pub name: String, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub deps: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub weak_deps: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub root_resolves: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub file: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub line: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub ctor_file: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub ctor_line: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub ctor_type: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub root_resolve_sites: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub scope_cached: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub scope_cache_sites: Option>, + #[serde(flatten)] + pub extra: Map, +} + +/// One reactive-effect binding row (P-020). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Binding { + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub name: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub init: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub refs: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub line: Option, + #[serde(flatten)] + pub extra: Map, +} + +/// One reactive effect (P-020, EFF001). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Effect { + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub deps: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub io: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub line: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub bindings: Option>, + #[serde(flatten)] + pub extra: Map, +} + +/// One function parameter (ownership contract, P-006/2b). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Param { + pub name: String, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub line: Option, + #[serde( + default, + deserialize_with = "nullable", + skip_serializing_if = "Option::is_none" + )] + pub effect: Option>, + #[serde(flatten)] + pub extra: Map, +} + +/// One per-method flow body (P-016). The body's `nodes` are deliberately +/// untyped here — their vocabulary is the bridge's concern, not the schema's. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct Function { + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub params: Option>, + #[serde(flatten)] + pub extra: Map, +} + +/// The `OwnIR` document root. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct OwnIr { + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub ownir_version: Option, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub components: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub services: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub effects: Option>, + #[serde( + default, + deserialize_with = "reject_null", + skip_serializing_if = "Option::is_none" + )] + pub functions: Option>, + #[serde(flatten)] + pub extra: Map, +} + +impl OwnIr { + /// Parse + shape-check an `OwnIR` JSON document. Mirrors the acceptance of + /// Python `ownlang.ownir.load` (version gate first, then field shapes). + /// + /// # Errors + /// [`OwnIrError`] on invalid JSON, a schema-version mismatch, or any field + /// that the reference implementation would reject. + pub fn from_json(text: &str) -> Result { + let doc: Self = serde_json::from_str(text) + .map_err(|e| OwnIrError(format!("OwnIR facts are not valid: {e}")))?; + doc.validate()?; + Ok(doc) + } + + /// The checks serde's typing cannot express: the version gate and the + /// non-empty-identity rules. + /// + /// # Errors + /// [`OwnIrError`] on a schema-version mismatch or an empty identity field. + pub fn validate(&self) -> Result<(), OwnIrError> { + let ver = self.ownir_version.unwrap_or(OWNIR_VERSION); + if ver != OWNIR_VERSION { + return Err(OwnIrError(format!( + "OwnIR facts are schema v{ver}, but this core understands \ + v{OWNIR_VERSION}. Build the extractor and the core from the \ + same commit — the OwnIR fact vocabulary changed between the \ + version that produced this file and the one reading it." + ))); + } + for s in self.services.iter().flatten() { + if s.name.is_empty() { + return Err(OwnIrError( + "service 'name' must be a non-empty string".to_owned(), + )); + } + } + for p in self + .functions + .iter() + .flatten() + .flat_map(|f| f.params.iter().flatten()) + { + if p.name.is_empty() { + return Err(OwnIrError( + "parameter 'name' must be a non-empty string".to_owned(), + )); + } + } + Ok(()) + } + + /// Serialize back to a JSON value. Together with `from_json` this is the + /// round-trip the oracle's first parity check rides on. + /// + /// # Errors + /// [`OwnIrError`] if serialization fails (it cannot for these types, but + /// the contract stays honest rather than panicking). + pub fn to_value(&self) -> Result { + serde_json::to_value(self).map_err(|e| OwnIrError(format!("serialize failed: {e}"))) + } +} diff --git a/rust/crates/own-ir/src/span.rs b/rust/crates/own-ir/src/span.rs new file mode 100644 index 00000000..34623c87 --- /dev/null +++ b/rust/crates/own-ir/src/span.rs @@ -0,0 +1,45 @@ +//! Span / location primitives — the *leaf* the whole workspace shares. +//! +//! Per P-022, these live in `own-ir` (not `own-syntax`) so the presentation +//! layer (`own-diagnostics`) can name a source position without dragging in +//! the parser. Internal positions are **byte offsets** plus one line index per +//! file (the ruff / rust-analyzer convention); line/column pairs are computed +//! only at the output seam. The types here are deliberately minimal — they +//! grow with the first real consumer (`own-syntax`), not speculatively. + +use serde::{Deserialize, Serialize}; + +/// A byte offset into a source file's UTF-8 text. `u32` suffices for any +/// source this tool will see and keeps `Span` at 8 bytes. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, +)] +#[serde(transparent)] +pub struct ByteOffset(pub u32); + +/// A half-open byte range `[start, end)` in one source file. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +pub struct Span { + pub start: ByteOffset, + pub end: ByteOffset, +} + +impl Span { + #[must_use] + pub const fn new(start: u32, end: u32) -> Self { + Self { + start: ByteOffset(start), + end: ByteOffset(end), + } + } + + #[must_use] + pub const fn len(self) -> u32 { + self.end.0.saturating_sub(self.start.0) + } + + #[must_use] + pub const fn is_empty(self) -> bool { + self.len() == 0 + } +} diff --git a/rust/crates/own-ir/tests/roundtrip.rs b/rust/crates/own-ir/tests/roundtrip.rs new file mode 100644 index 00000000..5a21cc60 --- /dev/null +++ b/rust/crates/own-ir/tests/roundtrip.rs @@ -0,0 +1,144 @@ +//! The first parity check of the migration (P-022 step 1): `own-ir` must +//! round-trip every `OwnIR` fixture the Python core's test suite uses, +//! value-for-value — typed fields and additive `extra` fields alike. + +// Tests fail by panicking — that IS their reporting mechanism, so the +// production bans on `panic!`/`expect` don't apply in this file (justified, +// file-scoped allow per the strictness doctrine in P-022). +#![allow(clippy::panic, clippy::expect_used)] + +use own_ir::{OwnIr, OWNIR_VERSION}; +use serde_json::Value; +use std::fs; +use std::path::PathBuf; + +fn fixtures_dir() -> PathBuf { + // rust/crates/own-ir -> repo root is three levels up. + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../tests/fixtures/ownir") +} + +#[test] +fn round_trips_every_python_fixture() { + let dir = fixtures_dir(); + let mut seen = 0u32; + let entries = fs::read_dir(&dir).expect("OwnIR fixture dir must exist (run from the repo)"); + for entry in entries { + let path = entry.expect("readable dir entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let text = fs::read_to_string(&path).expect("fixture must be readable"); + let original: Value = serde_json::from_str(&text).expect("fixture must be valid JSON"); + let doc = OwnIr::from_json(&text) + .unwrap_or_else(|e| panic!("{} must parse like Python load(): {e}", path.display())); + let back = doc.to_value().expect("round-trip serialization"); + assert_eq!( + back, + original, + "{} must round-trip value-for-value", + path.display() + ); + seen = seen.saturating_add(1); + } + assert!(seen >= 15, "expected the fixture corpus, found only {seen}"); +} + +#[test] +fn version_gate_rejects_future_schema() { + let err = OwnIr::from_json(r#"{"ownir_version": 1}"#).expect_err("v1 must be rejected"); + assert!( + err.0.contains("schema v1") && err.0.contains(&format!("v{OWNIR_VERSION}")), + "gate message must name both versions: {err}" + ); +} + +#[test] +fn absent_version_means_v0() { + let doc = OwnIr::from_json(r#"{"components": []}"#).expect("pre-versioning producers are v0"); + assert_eq!(doc.ownir_version, None); +} + +#[test] +fn bool_is_not_an_integer() { + // Python needs an explicit `isinstance(x, bool)` check because bool is an + // int subclass; Rust must reject it too for acceptance parity. + let res = + OwnIr::from_json(r#"{"services": [{"lifetime": "singleton", "name": "A", "line": true}]}"#); + assert!(res.is_err(), "a boolean 'line' must be rejected"); +} + +#[test] +fn lifetime_vocabulary_is_closed() { + let res = OwnIr::from_json(r#"{"services": [{"lifetime": "static", "name": "A"}]}"#); + assert!(res.is_err(), "an unknown lifetime must be rejected"); +} + +#[test] +fn empty_identity_fields_are_rejected() { + let res = OwnIr::from_json(r#"{"services": [{"lifetime": "scoped", "name": ""}]}"#); + assert!(res.is_err(), "an empty service name must be rejected"); + let res = OwnIr::from_json(r#"{"functions": [{"params": [{"name": ""}]}]}"#); + assert!(res.is_err(), "an empty parameter name must be rejected"); +} + +#[test] +fn param_effect_vocabulary_is_closed() { + let res = OwnIr::from_json(r#"{"functions": [{"params": [{"name": "s", "effect": "own"}]}]}"#); + assert!(res.is_err(), "an unknown param effect must be rejected"); + let ok = + OwnIr::from_json(r#"{"functions": [{"params": [{"name": "s", "effect": "borrow_mut"}]}]}"#); + assert!(ok.is_ok(), "borrow_mut is in the vocabulary"); +} + +#[test] +fn explicit_null_is_rejected_where_python_rejects_it() { + // Python: `result.get("components", [])` -> a present null fails the + // isinstance list check. Option alone would collapse null into + // "absent" and silently drop the field on round-trip. + for doc in [ + r#"{"components": null}"#, + r#"{"ownir_version": null}"#, + r#"{"services": [{"lifetime": "scoped", "name": "A", "deps": null}]}"#, + r#"{"components": [{"subscriptions": [{"resource": null}]}]}"#, + r#"{"functions": [{"params": [{"name": "s", "line": null}]}]}"#, + ] { + assert!( + OwnIr::from_json(doc).is_err(), + "a present null must be rejected (Python parity): {doc}" + ); + } +} + +#[test] +fn explicit_null_is_accepted_and_preserved_where_python_accepts_it() { + // Python checks these with `if x is not None and not isinstance(...)` — + // a present null passes AND stays in the document, so the round-trip + // must re-emit it rather than dropping the key. + for doc in [ + r#"{"components": [{"subscriptions": [{"type": null}]}]}"#, + r#"{"components": [{"subscriptions": [{"source_type": null}]}]}"#, + r#"{"functions": [{"params": [{"name": "s", "effect": null}]}]}"#, + ] { + let original: Value = serde_json::from_str(doc).expect("valid JSON"); + let parsed = OwnIr::from_json(doc) + .unwrap_or_else(|e| panic!("null must be accepted here: {doc}: {e}")); + assert_eq!( + parsed.to_value().expect("serialize"), + original, + "explicit null must survive the round-trip: {doc}" + ); + } +} + +#[test] +fn additive_unknown_fields_are_preserved() { + let text = r#"{ + "module": "M", + "future_top_level": {"x": 1}, + "components": [{"name": "C", "future_field": [1, 2], + "subscriptions": [{"event": "e", "released": false}]}] + }"#; + let original: Value = serde_json::from_str(text).expect("valid JSON"); + let doc = OwnIr::from_json(text).expect("additive fields are tolerated"); + assert_eq!(doc.to_value().expect("serialize"), original); +} diff --git a/scripts/oracle_exact.py b/scripts/oracle_exact.py new file mode 100644 index 00000000..c761cd3e --- /dev/null +++ b/scripts/oracle_exact.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +""" +Exact differential oracle — the Rust-migration parity harness (P-022). + +NOT `oracle_compare.py`. That script is a *cross-tool* fuzzy matcher (leak-class +only, ±N-line tolerance, coarse severity buckets) for comparing against external +tools with different conventions. This harness answers a different question — +"is the candidate implementation **byte-for-value identical** to the reference +on the same input?" — so it is exact by construction: + + 1. **exit/crash gate first**: statuses must match and neither side may have + crashed (a Python traceback / Rust panic has no SARIF representation, so + an output-only diff would score a crash as "no findings = parity"); + 2. **canonicalize, then diff**: JSON streams are parsed and re-dumped with + sorted keys (formatting-independent, nothing semantic dropped); the input + path is normalized to `` on every stream; trailing whitespace is + not significant; + 3. **stderr is compared too** (machine-format summaries live there), and a + non-JSON stdout is compared as normalized text. + +Modes: + compare run reference and candidate on one input, diff every surface + snapshot run the reference over a corpus, write golden snapshots + manifest + keyed by (corpus hash, reference-source hash) + verify run the candidate against existing snapshots; fails on divergence + AND on a stale manifest (EITHER key changed -> regenerate first). + The reference key is a content hash of the reference + implementation's source tree (--ref-src, default ownlang/) — not + the git commit, which changes on every commit including ones that + cannot affect the reference's behaviour. + +Usage: + oracle_exact.py compare FILE --ref "python -m ownlang" --cand "" + [--surface "check --format sarif"] [--surface ...] + oracle_exact.py snapshot DIR --out SNAPDIR --ref "python -m ownlang" + [--surface ...] [--ext .own] + oracle_exact.py verify SNAPDIR --cand "" + oracle_exact.py --selftest + +zero-dependency: stdlib only, like the rest of scripts/. Default surfaces are +the frozen contracts: `check --format sarif` (verdict seam) and +`cfg --format json` (CFG seam). +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import shlex +import subprocess +import sys +from pathlib import Path + +DEFAULT_SURFACES = ["check --format sarif", "cfg --format json"] + +# Crash signatures the exit gate refuses to diff past: a traceback/panic is a +# harness failure, not a comparable output. +_CRASH_MARKS = ("Traceback (most recent call last)", "panicked at", "RUST_BACKTRACE") + + +def _canon_text(text: str, input_path: str) -> str: + """Normalize a non-JSON stream: input path -> , strip trailing + whitespace per line and trailing newlines. Nothing semantic is dropped.""" + text = text.replace(input_path, "") + lines = [ln.rstrip() for ln in text.splitlines()] + return "\n".join(lines).rstrip("\n") + + +def _canon_stream(text: str, input_path: str) -> str: + """Canonicalize one output stream. If the whole stream parses as JSON it is + re-dumped with sorted keys (formatting-independent); otherwise it is + normalized as text. Path normalization applies in both cases.""" + stripped = text.strip() + if stripped.startswith(("{", "[")): + try: + doc = json.loads(stripped) + except json.JSONDecodeError: + return _canon_text(text, input_path) + canon = json.dumps(doc, indent=2, sort_keys=True) + return _canon_text(canon, input_path) + return _canon_text(text, input_path) + + +def _run(cmd: str, surface: str, input_path: str) -> dict[str, str | int]: + """Run ` ` and capture the full observable + record: exit status + canonicalized stdout/stderr + a crash flag.""" + argv = shlex.split(cmd) + shlex.split(surface) + [input_path] + proc = subprocess.run(argv, capture_output=True, text=True, check=False) + # A signal death (segfault/OOM-kill) prints no traceback/panic text; the + # negative POSIX returncode is the only trace, so it counts as a crash. + crashed = any(m in proc.stderr for m in _CRASH_MARKS) or proc.returncode < 0 + return { + "status": proc.returncode, + "stdout": _canon_stream(proc.stdout, input_path), + "stderr": _canon_text(proc.stderr, input_path), + "crashed": int(crashed), + } + + +def _diff_records(ref: dict, cand: dict, label: str) -> list[str]: + """Exact comparison of two observable records; the exit/crash gate runs + before any output diff (per P-022).""" + problems: list[str] = [] + if ref["crashed"] or cand["crashed"]: + who = "reference" if ref["crashed"] else "candidate" + problems.append(f"{label}: {who} CRASHED — refusing to diff output") + return problems + if ref["status"] != cand["status"]: + problems.append( + f"{label}: exit status differs (ref={ref['status']} " + f"cand={cand['status']}) — gate failed, output diff skipped") + return problems + for stream in ("stdout", "stderr"): + if ref[stream] != cand[stream]: + problems.append(f"{label}: {stream} differs") + return problems + + +def _git_commit() -> str: + try: + out = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, + text=True, check=False) + return out.stdout.strip() or "unknown" + except OSError: + return "unknown" + + +def _corpus_files(root: Path, ext: str) -> list[Path]: + return sorted(p for p in root.rglob(f"*{ext}") if p.is_file()) + + +def _corpus_hash(files: list[Path], root: Path) -> str: + """One hash over (relpath, content-hash) pairs — either a file edit or an + add/remove changes it, which is exactly the snapshot-staleness key.""" + h = hashlib.sha256() + for p in files: + h.update(str(p.relative_to(root)).encode()) + h.update(hashlib.sha256(p.read_bytes()).hexdigest().encode()) + return h.hexdigest() + + +def _tree_hash(root: Path) -> str: + """Content hash of the reference implementation's source tree — the + reference half of the manifest key. A behaviour-affecting change cannot + happen without a source change, and (unlike the git commit) it is stable + across commits that don't touch the reference.""" + h = hashlib.sha256() + for p in sorted(x for x in root.rglob("*") if x.is_file() + and "__pycache__" not in x.parts): + h.update(str(p.relative_to(root)).encode()) + h.update(hashlib.sha256(p.read_bytes()).hexdigest().encode()) + return h.hexdigest() + + +def _slug(rel: str, surface: str) -> str: + safe = rel.replace("/", "__").replace("\\", "__") + surf = surface.split()[0] + return f"{safe}.{surf}.json" + + +def cmd_compare(args: argparse.Namespace) -> int: + problems: list[str] = [] + for surface in args.surface or DEFAULT_SURFACES: + ref = _run(args.ref, surface, args.input) + cand = _run(args.cand, surface, args.input) + problems += _diff_records(ref, cand, f"{args.input} [{surface}]") + for p in problems: + print(f"DIVERGENCE: {p}") + if not problems: + print(f"parity: {args.input} identical on " + f"{len(args.surface or DEFAULT_SURFACES)} surface(s)") + return 1 if problems else 0 + + +def cmd_snapshot(args: argparse.Namespace) -> int: + root = Path(args.corpus) + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + files = _corpus_files(root, args.ext) + if not files: + print(f"no *{args.ext} files under {root}", file=sys.stderr) + return 2 + surfaces = args.surface or DEFAULT_SURFACES + for f in files: + rel = str(f.relative_to(root)) + for surface in surfaces: + rec = _run(args.ref, surface, str(f)) + rec["file"] = rel + rec["surface"] = surface + (out / _slug(rel, surface)).write_text( + json.dumps(rec, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + manifest = { + "corpus_root": str(root), + "corpus_ext": args.ext, + "corpus_hash": _corpus_hash(files, root), + "reference_src": args.ref_src, + "reference_hash": _tree_hash(Path(args.ref_src)), + "reference_commit": _git_commit(), # informational only + "surfaces": surfaces, + "files": [str(f.relative_to(root)) for f in files], + } + (out / "manifest.json").write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + print(f"snapshotted {len(files)} file(s) x {len(surfaces)} surface(s) " + f"-> {out}") + return 0 + + +def cmd_verify(args: argparse.Namespace) -> int: + snap = Path(args.snapshots) + manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8")) + root = Path(manifest["corpus_root"]) + files = _corpus_files(root, manifest["corpus_ext"]) + # Staleness gate: EITHER key changing (corpus hash / reference-source + # hash) means the snapshots must be regenerated, not silently diffed + # against. The reference key is re-derived from the manifest's own + # reference_src, so a Python-core edit with an unchanged corpus trips it. + if _corpus_hash(files, root) != manifest["corpus_hash"]: + print("STALE SNAPSHOTS: the corpus changed since `snapshot` ran " + "(corpus_hash mismatch). Regenerate before verifying.", + file=sys.stderr) + return 2 + if _tree_hash(Path(manifest["reference_src"])) != manifest["reference_hash"]: + print("STALE SNAPSHOTS: the reference implementation changed since " + "`snapshot` ran (reference_hash mismatch). Regenerate before " + "verifying.", file=sys.stderr) + return 2 + problems: list[str] = [] + for rel in manifest["files"]: + for surface in manifest["surfaces"]: + rec_path = snap / _slug(rel, surface) + ref = json.loads(rec_path.read_text(encoding="utf-8")) + cand = _run(args.cand, surface, str(root / rel)) + problems += _diff_records(ref, cand, f"{rel} [{surface}]") + for p in problems: + print(f"DIVERGENCE: {p}") + total = len(manifest["files"]) * len(manifest["surfaces"]) + if not problems: + print(f"parity: {total} record(s) identical " + f"(reference commit {manifest['reference_commit'][:12]})") + return 1 if problems else 0 + + +# --------------------------------------------------------------------------- + + +def _selftest() -> int: + """Self-contained proof the harness can (a) see parity when both sides are + the same implementation and (b) see a divergence when they are not.""" + import tempfile + + fails: list[str] = [] + py = f"{shlex.quote(sys.executable)} -m ownlang" + + with tempfile.TemporaryDirectory() as td: + tdp = Path(td) + leak = tdp / "leak.own" + leak.write_text( + "module M\nresource Conn { acquire open release close }\n" + "fn f() {\n let c = acquire Conn(1);\n use c;\n}\n", + encoding="utf-8") + clean = tdp / "clean.own" + clean.write_text("module M\nfn f() {\n}\n", encoding="utf-8") + + # (a) same implementation on both sides => parity on every surface. + ns = argparse.Namespace(input=str(leak), ref=py, cand=py, surface=None) + if cmd_compare(ns) != 0: + fails.append("self-parity must hold (python vs python)") + + # (b) records for different inputs must diverge (stdout + status). + r1 = _run(py, "check --format sarif", str(leak)) + r2 = _run(py, "check --format sarif", str(clean)) + if not _diff_records(r1, r2, "x"): + fails.append("distinct inputs must produce a divergence") + + # (c) crash gate: a crashed record refuses the output diff. + crashed = dict(r1, crashed=1) + d = _diff_records(crashed, r1, "x") + if not (d and "CRASHED" in d[0]): + fails.append("crash gate must trip before any output diff") + + # (d) canonicalization: key order / path spelling are not divergences. + a = _canon_stream('{"b": 1, "a": [1, 2]}', "/x") + b = _canon_stream('{\n "a": [1, 2],\n "b": 1\n}', "/x") + if a != b: + fails.append("JSON canonicalization must ignore formatting/order") + + # (e) snapshot -> verify round-trip is parity; then EITHER key of the + # manifest going stale (reference source edit, corpus edit) must be + # refused with rc 2, in that order of checks. + refsrc = tdp / "refsrc" + refsrc.mkdir() + (refsrc / "core.py").write_text("VERSION = 1\n", encoding="utf-8") + snapdir = tdp / "snaps" + ns = argparse.Namespace(corpus=str(tdp), out=str(snapdir), ref=py, + surface=["check --format sarif"], ext=".own", + ref_src=str(refsrc)) + if cmd_snapshot(ns) != 0: + fails.append("snapshot must succeed") + nv = argparse.Namespace(snapshots=str(snapdir), cand=py) + if cmd_verify(nv) != 0: + fails.append("verify vs the same implementation must be parity") + (refsrc / "core.py").write_text("VERSION = 2\n", encoding="utf-8") + if cmd_verify(nv) != 2: + fails.append("a reference-source edit must make snapshots STALE") + (refsrc / "core.py").write_text("VERSION = 1\n", encoding="utf-8") + if cmd_verify(nv) != 0: + fails.append("restoring the reference source must restore parity") + clean.write_text("module M\nfn g() {\n}\n", encoding="utf-8") + if cmd_verify(nv) != 2: + fails.append("a corpus edit must make snapshots STALE (rc 2)") + + for f in fails: + print(f"ORACLE-EXACT SELFTEST FAIL: {f}") + print(f"oracle_exact selftest: {'FAIL' if fails else 'PASS'}") + return 1 if fails else 0 + + +def main(argv: list[str]) -> int: + if "--selftest" in argv: + return _selftest() + ap = argparse.ArgumentParser(description=__doc__.splitlines()[1]) + sub = ap.add_subparsers(dest="mode", required=True) + + c = sub.add_parser("compare", help="diff candidate vs reference on one input") + c.add_argument("input") + c.add_argument("--ref", required=True, help="reference command prefix") + c.add_argument("--cand", required=True, help="candidate command prefix") + c.add_argument("--surface", action="append", + help=f"CLI surface to diff (default: {DEFAULT_SURFACES})") + + s = sub.add_parser("snapshot", help="write golden snapshots of the reference") + s.add_argument("corpus", help="corpus root directory") + s.add_argument("--out", required=True) + s.add_argument("--ref", required=True) + s.add_argument("--surface", action="append") + s.add_argument("--ext", default=".own") + s.add_argument("--ref-src", default="ownlang", dest="ref_src", + help="reference implementation source tree (hash = the " + "reference half of the staleness key)") + + v = sub.add_parser("verify", help="diff the candidate against snapshots") + v.add_argument("snapshots", help="snapshot directory (with manifest.json)") + v.add_argument("--cand", required=True) + + args = ap.parse_args(argv) + if args.mode == "compare": + return cmd_compare(args) + if args.mode == "snapshot": + return cmd_snapshot(args) + return cmd_verify(args) + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/run_tests.py b/tests/run_tests.py index f10874f1..a2fcb3bd 100644 --- a/tests/run_tests.py +++ b/tests/run_tests.py @@ -1134,6 +1134,12 @@ def run() -> int: import test_diag_sarif dsarif_rc = test_diag_sarif.run() + # Canonical CFG JSON seam (P-022 step 0): the frozen CFG-layer contract the + # Rust-port differential oracle diffs — envelope, symbol-table identity, + # instruction vocabulary, determinism. + import test_cfg_json + cfgjson_rc = test_cfg_json.run() + # Reactive-effect stability (P-020): the EFF001 effect-storm analysis — the # identity lattice, reference propagation, cycle safety, and the OwnIR bridge # mapping the optional `effects` block to an EFF001 finding (a new core @@ -1146,7 +1152,8 @@ def run() -> int: or order_fails or helper_fails or cc_rc or pf_rc or gl_rc or co_rc or wpf_rc or lt_rc or loops_rc or spec_rc or ownir_rc or own5_rc or rid_rc or diag_rc - or explain_rc or effects_rc or evid_rc or dsarif_rc) else 0 + or explain_rc or effects_rc or evid_rc or dsarif_rc + or cfgjson_rc) else 0 if __name__ == "__main__": diff --git a/tests/test_cfg_json.py b/tests/test_cfg_json.py new file mode 100644 index 00000000..bcb3fdd9 --- /dev/null +++ b/tests/test_cfg_json.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Canonical CFG JSON seam (P-022 migration step 0, `ownlang.cfg_json`). + +This is the CFG-layer contract the Rust-port differential oracle diffs against, +so the test pins the *shape*, not just "it emits something": + + 1. the versioned envelope (`ownlang_cfg_version`) and per-function fields; + 2. the symbol table: first-appearance order, params first, and — the part a + port gets wrong first — **identity structure**: a borrow binding is a + distinct symbol row from its owner, and an instruction referencing the + same symbol twice yields the same index; + 3. the instruction `op` vocabulary for a fixture exercising every variant the + surface language can produce from source today; + 4. determinism: two projections of the same module are deeply equal, and the + dumped JSON (sorted keys) is byte-identical. + +Run: python tests/test_cfg_json.py + python tests/run_tests.py (as part of the suite) +""" + +from __future__ import annotations + +import json +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from ownlang.cfg import build_cfg, collect_kinds, collect_policies, collect_signatures +from ownlang.cfg_json import CFG_JSON_VERSION, module_cfg_json +from ownlang.parser import parse + +_SRC = ( + "module M\n" # 1 + "resource Conn { kind \"connection token\" acquire open release close }\n" # 2 + "extern fn Store(consume Conn);\n" # 3 + "fn f(n: int) {\n" # 4 + " let c = acquire Conn(1);\n" # 5 + " if (n) {\n" # 6 + " borrow c as r { use r; }\n" # 7 + " }\n" # 8 + " let d = move c;\n" # 9 + " Store(d);\n" # 10 + " return;\n" # 11 + "}\n" + "fn g(n: int) {\n" # 13 + " let b = Buffer.scratch(n);\n" # 14 + " release b;\n" # 15 + "}\n" # 16 +) + + +def _doc() -> dict: + mod = parse(_SRC) + rnames = {r.name for r in mod.resources} + sigs = collect_signatures(mod) + pols = collect_policies(mod) + kinds = collect_kinds(mod) + cfgs = [build_cfg(fn, rnames, sigs, pols, kinds)[0] for fn in mod.functions] + return module_cfg_json(cfgs) + + +def run() -> int: + fails: list[str] = [] + checks = 0 + + def expect(cond: bool, msg: str) -> None: + nonlocal checks + checks += 1 + if not cond: + fails.append(msg) + + doc = _doc() + + # -- envelope ------------------------------------------------------------ + expect(doc["ownlang_cfg_version"] == CFG_JSON_VERSION == 0, + f"version envelope wrong: {doc.get('ownlang_cfg_version')!r}") + expect([f["name"] for f in doc["functions"]] == ["f", "g"], + "functions must appear in module order") + + f = doc["functions"][0] + expect(set(f) == {"name", "entry", "has_return_type", "params", "symbols", + "blocks"}, + f"per-function field set drifted: {sorted(f)}") + + # -- symbol table: params first, identity preserved ---------------------- + syms = f["symbols"] + expect(f["params"] == [0] and syms[0]["name"] == "n" + and syms[0]["kind"] == "plain", + f"param must be symbol 0: {f['params']} / {syms[:1]}") + by_name = {s["name"]: i for i, s in enumerate(syms)} + expect({"n", "c", "r", "d"} <= set(by_name), + f"expected symbols missing from table: {sorted(by_name)}") + expect(syms[by_name["r"]]["kind"] == "borrow" + and syms[by_name["c"]]["kind"] == "owned", + "borrow binding must be a distinct row with kind=borrow") + # resource kinds must ride the seam (the CLI path passes collect_kinds; + # dropping it would null the field for every kind-tagged resource). + expect(syms[by_name["c"]]["resource_kind"] == "connection token", + f"resource_kind must survive projection: {syms[by_name['c']]}") + + # -- op vocabulary + same-symbol references share an index --------------- + ops = [i for b in f["blocks"] for i in b["instrs"]] + op_names = [i["op"] for i in ops] + for expected in ("acquire", "borrow_start", "use", "borrow_end", + "move_into", "invoke", "return"): + expect(expected in op_names, f"op {expected!r} missing: {op_names}") + bs = next(i for i in ops if i["op"] == "borrow_start") + be = next(i for i in ops if i["op"] == "borrow_end") + expect(bs["owner"] == be["owner"] == by_name["c"] + and bs["binding"] == be["binding"] == by_name["r"], + "borrow start/end must reference the same owner/binding indices") + mv = next(i for i in ops if i["op"] == "move_into") + expect(mv["src"] == by_name["c"] and mv["dst"] == by_name["d"], + f"move_into must link src=c dst=d by index: {mv}") + inv = next(i for i in ops if i["op"] == "invoke") + expect(inv["callee"] == "Store" + and inv["args"] == [{"sym": by_name["d"], "effect": "consume"}], + f"invoke args must carry (sym index, effect): {inv}") + + # -- buffers ride along on the second function --------------------------- + g = doc["functions"][1] + ab = next(i for b in g["blocks"] for i in b["instrs"] + if i["op"] == "acquire_buffer") + expect(ab["buffer"]["mode"] == "scratch" and ab["buffer"]["line"] == 14, + f"acquire_buffer must carry the resolved policy: {ab['buffer']}") + + # -- determinism ---------------------------------------------------------- + expect(_doc() == doc, "two projections of the same module must be equal") + dump = json.dumps(doc, indent=2, sort_keys=True) + expect(dump == json.dumps(_doc(), indent=2, sort_keys=True), + "sorted-key dumps must be byte-identical") + + for msg in fails: + print(f"CFG-JSON FAIL: {msg}") + print(f"cfg_json: {checks - len(fails)}/{checks} CFG-seam checks pass") + return 1 if fails else 0 + + +if __name__ == "__main__": + raise SystemExit(run())