From 3e15b757b35a18de49d94a9891093308128504c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 18:26:11 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(rust):=20#259=20slice=202=20=E2=80=94?= =?UTF-8?q?=20own-lowered,=20typed=20Layer=202=20replay/emitter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typed Rust half of the frozen Layer 2 contract (LOWERED_VERSION 1): a strict data model of the normalized lowered representation plus the canonical emitter, replaying the Python-authored goldens byte-for-byte. No lowering, no OwnIR validation, no MOS, no analysis wiring — this slice proves Rust can carry and emit the exact surface before anything derives it. * crates/own-lowered — a LEAF data crate (own-diagnostics precedent; the DAG lock in own-diagnostics/tests/dag.rs is widened deliberately with the leaf entry and the rationale: the future own-bridge will CONSTRUCT these types, never the reverse). Model mirrors ownlang/lowered.py's frozen normalization decisions field-for-field: declaration order = canonical JSON order; always-written nullable fields are Option WITHOUT skip; the handle-entry allowlist keys are Option WITH skip, `handle` first; the closed Stmt vocabulary under the `stmt` tag; Rejected carries the fail-loud error text. Every shape is deny_unknown_fields — a Python-side surface change cannot slip past the typed replay. * tests/replay.rs — reads manifest.json (typed, strict), requires lowered_version == LOWERED_VERSION, both fixture halves on disk for every case, then for each rust_replay: true case parses the golden and asserts the canonical re-emit is byte-identical (>= 25 shared cases). The rust_replay: false set is asserted to be exactly the OD-2/#294 snapshot — Python-only by ledger decision, not silently skipped. serde_json's pretty printer reproduces Python json.dumps(indent=2, ensure_ascii=False) exactly on this surface — proven by the byte-equality suite, not assumed. cargo fmt/clippy clean, full workspace tests green (incl. the DAG fitness test), Python suite untouched and green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MdYFKUaygHz1T9H1qJ7BqK --- rust/Cargo.lock | 8 + rust/Cargo.toml | 2 +- rust/crates/own-diagnostics/tests/dag.rs | 7 + rust/crates/own-lowered/Cargo.toml | 22 ++ rust/crates/own-lowered/src/lib.rs | 27 +++ rust/crates/own-lowered/src/model.rs | 255 +++++++++++++++++++++++ rust/crates/own-lowered/tests/replay.rs | 86 ++++++++ 7 files changed, 406 insertions(+), 1 deletion(-) create mode 100644 rust/crates/own-lowered/Cargo.toml create mode 100644 rust/crates/own-lowered/src/lib.rs create mode 100644 rust/crates/own-lowered/src/model.rs create mode 100644 rust/crates/own-lowered/tests/replay.rs diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5613e68d..3ba870aa 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -48,6 +48,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "own-lowered" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "own-syntax" version = "0.1.0" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 223a934f..56089b5b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,7 +8,7 @@ [workspace] resolver = "2" -members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis"] +members = ["crates/own-ir", "crates/own-syntax", "crates/own-cfg", "crates/own-diagnostics", "crates/own-analysis", "crates/own-lowered"] [workspace.package] edition = "2021" diff --git a/rust/crates/own-diagnostics/tests/dag.rs b/rust/crates/own-diagnostics/tests/dag.rs index 0d6f0dc3..eab21233 100644 --- a/rust/crates/own-diagnostics/tests/dag.rs +++ b/rust/crates/own-diagnostics/tests/dag.rs @@ -33,6 +33,13 @@ fn allowed_edges() -> HashMap<&'static str, BTreeSet<&'static str>> { m.insert("own-cfg", ["own-ir", "own-syntax"].into_iter().collect()); // The invariant #214 is about: only the span leaf, never the solver/parser. m.insert("own-diagnostics", std::iter::once("own-ir").collect()); + // The Layer 2 parity surface (#259): a DATA leaf like own-diagnostics — + // the typed model + canonical emitter of the normalized lowered + // representation. It deliberately depends on NO workspace crate: the + // future own-bridge will CONSTRUCT these types (own-bridge → own-lowered), + // never the reverse, and the surface must stay implementable without the + // lowering that fills it. + m.insert("own-lowered", BTreeSet::new()); // own-analysis CONSTRUCTS diagnostics and consumes the cfg lowering. It reads // the effect type through `own_cfg::Effect`, NOT the parser — so there is no // production own-syntax edge (own-syntax is a dev-only edge for its tests). diff --git a/rust/crates/own-lowered/Cargo.toml b/rust/crates/own-lowered/Cargo.toml new file mode 100644 index 00000000..72ee3a26 --- /dev/null +++ b/rust/crates/own-lowered/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "own-lowered" +description = "Layer 2 normalized-lowered-representation surface: typed model + canonical emitter replaying the Python-authored goldens (tests/fixtures/lowered, LOWERED_VERSION 1) — the data half of the own-bridge parity contract (P-022 #259); no lowering, no MOS, no analysis wiring" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true +version = "0.1.0" + +# A LEAF data crate, like own-diagnostics: it models the Layer 2 JSON surface +# (spec/Bridge.md §6, ownlang/lowered.py is the authoritative Python twin) and +# re-emits it byte-for-byte. It deliberately depends on NO other own-* crate — +# the future own-bridge will construct these types from real lowering; this +# crate must stay implementable without it (typed replay first, derivation +# later). serde_json is a REGULAR dependency: the canonical emitter is the +# crate's purpose, not a test aid. +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[lints] +workspace = true diff --git a/rust/crates/own-lowered/src/lib.rs b/rust/crates/own-lowered/src/lib.rs new file mode 100644 index 00000000..288aad4e --- /dev/null +++ b/rust/crates/own-lowered/src/lib.rs @@ -0,0 +1,27 @@ +//! The Layer 2 parity surface, typed (P-022 #259, spec/Bridge.md §6). +//! +//! `ownlang/lowered.py` is the authoritative Python emitter of the normalized +//! lowered representation; `tests/fixtures/lowered/` holds its frozen +//! facts/golden pairs under `manifest.json`. This crate is the **typed Rust +//! half of that contract**: a strict (`deny_unknown_fields`) data model of the +//! surface plus the canonical emitter that re-serializes it **byte-for-byte** +//! (2-space indent, fixed field order, raw UTF-8, trailing newline). +//! +//! Deliberately NOT here (next slices, gated separately): deriving these +//! documents from `OwnIR` facts (the lowering itself), `OwnIR` validation, +//! MOS inference, and any analysis wiring. A `rust_replay: false` manifest +//! case is a Python-only behavior snapshot pinning an open decision (#294) +//! and is not replayed by this crate's parity suite. +//! +//! Every shape here mirrors the frozen normalization decisions in the Python +//! emitter's docstring; a field added there without a matching change here (or +//! vice-versa) fails the replay suite, and `LOWERED_VERSION` must move in +//! lockstep on both sides. + +mod model; + +pub use model::{ + parse_document, to_canonical_json, Extern, ExternParam, Function, HandleEntry, Lifetime, + LoweredDocument, Manifest, ManifestCase, Param, Rejected, Resource, ResourceMember, Stmt, + Surface, TypeShape, LOWERED_VERSION, +}; diff --git a/rust/crates/own-lowered/src/model.rs b/rust/crates/own-lowered/src/model.rs new file mode 100644 index 00000000..4c51656c --- /dev/null +++ b/rust/crates/own-lowered/src/model.rs @@ -0,0 +1,255 @@ +//! Typed model of the Layer 2 document and its manifest ledger. +//! +//! Field ORDER in every struct is normative: serde serializes declaration +//! order, and the canonical emitter must reproduce `ownlang/lowered.py`'s +//! construction order byte-for-byte. Optional keys exist in exactly two +//! flavours, mirroring the Python emitter: fields Python always writes +//! (possibly `null`) are `Option` WITHOUT skip; the handle-entry allowlist +//! keys Python writes only-when-present are `Option` with +//! `skip_serializing_if`. + +use serde::{Deserialize, Serialize}; + +/// The Layer 2 surface version — must equal `ownlang/lowered.py`'s +/// `LOWERED_VERSION` and `manifest.json`'s `lowered_version`. +pub const LOWERED_VERSION: u32 = 1; + +/// One parsed golden: either a full lowered document or a fail-loud rejection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Surface { + /// A lowered Module + handle map. + Lowered(LoweredDocument), + /// An `OwnIRError` rejection whose message text is part of the surface. + Rejected(Rejected), +} + +/// `{"lowered_version": ..., "error": ...}` — a vocabulary-skew rejection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Rejected { + pub lowered_version: u32, + pub error: String, +} + +/// The full Layer 2 document (field order is the canonical JSON order). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LoweredDocument { + pub lowered_version: u32, + pub module: String, + pub resources: Vec, + pub externs: Vec, + pub lifetimes: Vec, + pub functions: Vec, + pub handles: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Resource { + pub name: String, + /// Always present, possibly `null` (the human `[resource: ...]` tag). + pub kind: Option, + pub members: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ResourceMember { + pub role: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Extern { + pub name: String, + pub params: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExternParam { + pub effect: String, + #[serde(rename = "type")] + pub type_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Lifetime { + pub name: String, + /// The strictly-longer region, or `null` for a root region. + pub longer: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Function { + pub name: String, + /// The subscriber region, or `null` when no capture was minted. + pub lifetime: Option, + pub params: Vec, + /// The synthesized owned return type, or `null` for a void body. + pub ret: Option, + pub body: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Param { + pub handle: String, + #[serde(rename = "type")] + pub type_shape: Option, + pub line: i64, + pub lifetime: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TypeShape { + pub name: String, + pub borrowed: bool, + pub mutable: bool, +} + +/// The closed statement vocabulary under the `stmt` discriminator. Adding a +/// variant is a Layer 2 contract change (version bump on both sides). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "stmt", rename_all = "snake_case", deny_unknown_fields)] +pub enum Stmt { + Acquire { + handle: String, + resource: String, + line: i64, + }, + Release { + handle: String, + line: i64, + }, + Use { + handle: String, + line: i64, + }, + Overspan { + handle: String, + line: i64, + }, + Return { + /// `null` = a bare return (no owned value). + handle: Option, + line: i64, + }, + AliasJoin { + handle: String, + src: String, + line: i64, + }, + Call { + callee: String, + args: Vec, + line: i64, + }, + Subscribe { + source: String, + line: i64, + }, + If { + cond: String, + then: Vec, + #[serde(rename = "else")] + r#else: Vec, + line: i64, + }, + While { + cond: String, + body: Vec, + line: i64, + }, +} + +/// One normalized handle-map entry: `handle` first, then the allowlist keys in +/// fixed order, each present only when the underlying record carried it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HandleEntry { + pub handle: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub component: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub handler: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub resource: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub released: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub di_source_life: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ever_released: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pool: Option, +} + +/// `tests/fixtures/lowered/manifest.json` — the frozen case ledger. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub comment: String, + pub lowered_version: u32, + pub cases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManifestCase { + pub name: String, + pub rules: Vec, + pub rust_replay: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option, +} + +/// Parse one golden document, strictly typed. +/// +/// A JSON object carrying `error` is a [`Rejected`], anything else must be a +/// full [`LoweredDocument`] — unknown fields fail in both shapes, so a +/// Python-side surface change cannot slip past the typed replay. +/// +/// # Errors +/// Returns the underlying `serde_json` error when the text is not valid JSON +/// or does not match the closed Layer 2 shapes. +pub fn parse_document(text: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(text)?; + if value.get("error").is_some() { + return serde_json::from_value::(value).map(Surface::Rejected); + } + serde_json::from_value::(value).map(Surface::Lowered) +} + +/// The canonical serialized form — byte-identical to the Python emitter's +/// `render_lowered`: 2-space pretty JSON, declaration field order, raw UTF-8, +/// one trailing newline. +/// +/// # Errors +/// Returns the underlying `serde_json` error if serialization fails (it +/// cannot for these closed types, but the emitter refuses to panic). +pub fn to_canonical_json(surface: &Surface) -> Result { + let mut out = match surface { + Surface::Lowered(doc) => serde_json::to_string_pretty(doc)?, + Surface::Rejected(rej) => serde_json::to_string_pretty(rej)?, + }; + out.push('\n'); + Ok(out) +} diff --git a/rust/crates/own-lowered/tests/replay.rs b/rust/crates/own-lowered/tests/replay.rs new file mode 100644 index 00000000..993bc1a6 --- /dev/null +++ b/rust/crates/own-lowered/tests/replay.rs @@ -0,0 +1,86 @@ +//! Replays the shared Layer 2 goldens — the Rust side of +//! `tests/test_lowered_fixtures.py` (authoritative: Python regenerates the +//! goldens with `--write`; this suite must reproduce every shared one +//! byte-for-byte from the typed model). +//! +//! Contract (spec/Bridge.md §6 + the manifest ledger): +//! * every `rust_replay: true` case's golden must PARSE into the strict typed +//! model (`deny_unknown_fields` — a Python-side surface change cannot slip +//! past) and RE-EMIT byte-identically through the canonical emitter; +//! * a `rust_replay: false` case is a Python-only behavior snapshot pinning an +//! open decision (#294) — it is deliberately NOT replayed, and the manifest +//! must name the decision it waits on; +//! * the manifest's `lowered_version` must equal this crate's +//! `LOWERED_VERSION`, and every case must have both fixture files on disk — +//! the ledger and the tree cannot drift apart on the Rust side either. + +#![allow(clippy::panic, clippy::expect_used)] + +use own_lowered::{parse_document, to_canonical_json, Manifest, LOWERED_VERSION}; + +const FIXDIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../tests/fixtures/lowered" +); + +fn read(name: &str) -> String { + let path = format!("{FIXDIR}/{name}"); + std::fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "cannot read {path}: {e} — regenerate: python tests/test_lowered_fixtures.py --write" + ) + }) +} + +#[test] +fn replays_python_authored_goldens() { + let manifest: Manifest = + serde_json::from_str(&read("manifest.json")).expect("manifest.json parses (typed, strict)"); + assert_eq!( + manifest.lowered_version, LOWERED_VERSION, + "manifest lowered_version must match own-lowered::LOWERED_VERSION" + ); + assert!(!manifest.cases.is_empty(), "manifest must not be empty"); + + let mut replayed = 0_u32; + let mut skipped = Vec::new(); + for case in &manifest.cases { + // both fixture halves must exist regardless of replay mode. + let golden = read(&format!("{}.golden.json", case.name)); + let _facts_exists = read(&format!("{}.facts.json", case.name)); + + if !case.rust_replay { + assert!( + case.decision.is_some(), + "{}: a Python-only case must name the open decision it pins", + case.name + ); + skipped.push(case.name.clone()); + continue; + } + let surface = parse_document(&golden).unwrap_or_else(|e| { + panic!( + "{}: golden does not match the typed surface: {e}", + case.name + ) + }); + let emitted = to_canonical_json(&surface) + .unwrap_or_else(|e| panic!("{}: canonical emit failed: {e}", case.name)); + assert!( + emitted == golden, + "{}: canonical re-emit is not byte-identical to the Python golden", + case.name + ); + replayed = replayed.checked_add(1).expect("case count fits u32"); + } + assert!( + replayed >= 25, + "expected at least 25 shared cases, replayed {replayed}" + ); + assert_eq!( + skipped, + vec!["tolerant_unknown_kind".to_owned()], + "exactly the OD-2 (#294) snapshot is Python-only today; changing this \ + set is a deliberate contract decision" + ); +} From 8c41e2225e182b3c410385c64860daa457899528 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 18:51:22 +0000 Subject: [PATCH 2/3] =?UTF-8?q?test(rust):=20red=20=E2=80=94=20#300=20revi?= =?UTF-8?q?ew:=20null-presence,=20version=20gate,=20ledger=20equality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The typed model collapses a MISSING handle key and an explicit null into one state, so a valid Python golden carrying the schema-nullable trio (type/source/source_type as null) cannot round-trip byte-exactly. Pinned by a new shared fixture (handles_null_metadata, rust_replay: true) whose first handle carries all three explicit nulls and whose second carries none — the Python emitter distinguishes them by key membership. Also pinned red: * lowered_version parsed but never enforced — version 99 documents (accepted and rejected surfaces alike) currently parse fine; * Param.type is nullable in Rust while Python's AST declares TypeRef, never TypeRef | None — "type": null must be rejected; * a non-nullable optional handle key with explicit null (released: null) is silently decayed to missing instead of rejected; * the replay suite outsourced ledger integrity to Python — it now asserts unique(manifest names) == facts files == golden files itself (green from birth; enforcement, not a bug pin). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MdYFKUaygHz1T9H1qJ7BqK --- rust/crates/own-lowered/tests/replay.rs | 43 +++++- rust/crates/own-lowered/tests/strictness.rs | 135 +++++++++++++++++ .../lowered/handles_null_metadata.facts.json | 27 ++++ .../lowered/handles_null_metadata.golden.json | 137 ++++++++++++++++++ tests/fixtures/lowered/manifest.json | 8 + 5 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 rust/crates/own-lowered/tests/strictness.rs create mode 100644 tests/fixtures/lowered/handles_null_metadata.facts.json create mode 100644 tests/fixtures/lowered/handles_null_metadata.golden.json diff --git a/rust/crates/own-lowered/tests/replay.rs b/rust/crates/own-lowered/tests/replay.rs index 993bc1a6..b43345c0 100644 --- a/rust/crates/own-lowered/tests/replay.rs +++ b/rust/crates/own-lowered/tests/replay.rs @@ -11,12 +11,16 @@ //! open decision (#294) — it is deliberately NOT replayed, and the manifest //! must name the decision it waits on; //! * the manifest's `lowered_version` must equal this crate's -//! `LOWERED_VERSION`, and every case must have both fixture files on disk — -//! the ledger and the tree cannot drift apart on the Rust side either. +//! `LOWERED_VERSION`, and the ledger must equal the tree EXACTLY — +//! `unique(manifest names) == facts files == golden files`. The zero-Python +//! steady state means this suite cannot outsource ledger integrity to the +//! Python harness: a duplicate manifest name, an unlisted facts file, or an +//! orphaned golden is a red build here too. #![allow(clippy::panic, clippy::expect_used)] use own_lowered::{parse_document, to_canonical_json, Manifest, LOWERED_VERSION}; +use std::collections::BTreeSet; const FIXDIR: &str = concat!( env!("CARGO_MANIFEST_DIR"), @@ -42,12 +46,43 @@ fn replays_python_authored_goldens() { ); assert!(!manifest.cases.is_empty(), "manifest must not be empty"); + // Ledger/tree equality, independently of Python: the manifest names must + // be unique and equal BOTH on-disk filename sets exactly. + let mut listed = BTreeSet::new(); + for case in &manifest.cases { + assert!( + listed.insert(case.name.clone()), + "duplicate manifest case name: {}", + case.name + ); + } + let mut facts_files = BTreeSet::new(); + let mut golden_files = BTreeSet::new(); + for entry in std::fs::read_dir(FIXDIR).expect("fixture directory is readable") { + let file = entry.expect("directory entry").file_name(); + let file = file.to_str().expect("fixture filenames are UTF-8"); + if let Some(stem) = file.strip_suffix(".facts.json") { + facts_files.insert(stem.to_owned()); + } else if let Some(stem) = file.strip_suffix(".golden.json") { + golden_files.insert(stem.to_owned()); + } + } + assert_eq!( + listed, facts_files, + "manifest case names != *.facts.json on disk — the ledger and the \ + tree may not drift (unlisted facts file, or a listed case whose \ + facts are gone)" + ); + assert_eq!( + listed, golden_files, + "manifest case names != *.golden.json on disk — regenerate with \ + python tests/test_lowered_fixtures.py --write or fix the ledger" + ); + let mut replayed = 0_u32; let mut skipped = Vec::new(); for case in &manifest.cases { - // both fixture halves must exist regardless of replay mode. let golden = read(&format!("{}.golden.json", case.name)); - let _facts_exists = read(&format!("{}.facts.json", case.name)); if !case.rust_replay { assert!( diff --git a/rust/crates/own-lowered/tests/strictness.rs b/rust/crates/own-lowered/tests/strictness.rs new file mode 100644 index 00000000..54f65527 --- /dev/null +++ b/rust/crates/own-lowered/tests/strictness.rs @@ -0,0 +1,135 @@ +//! Negative model tests: the typed surface must reject impossible documents +//! and preserve presence semantics — `deny_unknown_fields` alone is strict +//! about surplus keys but says nothing about impossible VALUES, and the +//! Python emitter distinguishes a MISSING handle key from an explicit `null` +//! (`{k: rec[k] for k in _HANDLE_KEYS if k in rec}` — membership, not truth). +//! +//! Pinned here (#300 review): +//! * `lowered_version` is enforced on every parsed surface, accepted and +//! rejected alike — not just on the manifest; +//! * a parameter's `type` is non-nullable (`Param.type: TypeRef` in the +//! Python AST — the emitter always writes an object); +//! * the schema-nullable handle keys (`type`, `source`, `source_type`) keep +//! explicit `null` through a parse→emit round trip; +//! * a non-nullable optional handle key with an explicit `null` is rejected, +//! never silently deleted. + +#![allow(clippy::panic, clippy::expect_used)] + +use own_lowered::{parse_document, to_canonical_json}; + +/// A minimal full document; `handles` is spliced in so each test controls +/// exactly the entries under scrutiny. +fn doc(version: u32, handles: &str) -> String { + format!( + r#"{{ + "lowered_version": {version}, + "module": "m", + "resources": [], + "externs": [], + "lifetimes": [], + "functions": [], + "handles": {handles} +}}"# + ) +} + +#[test] +fn rejects_wrong_version_on_a_lowered_document() { + let err = parse_document(&doc(99, "[]")) + .expect_err("lowered_version 99 must not parse — the crate docs promise lockstep"); + assert!( + err.to_string().contains("lowered_version"), + "the rejection must name the version field, got: {err}" + ); +} + +#[test] +fn rejects_wrong_version_on_a_rejection_document() { + let err = parse_document(r#"{"lowered_version": 99, "error": "boom"}"#) + .expect_err("a rejection surface with lowered_version 99 must not parse"); + assert!( + err.to_string().contains("lowered_version"), + "the rejection must name the version field, got: {err}" + ); +} + +#[test] +fn accepts_the_current_version_on_a_rejection_document() { + let text = "{\n \"lowered_version\": 1,\n \"error\": \"boom\"\n}\n"; + let surface = parse_document(text).expect("a current-version rejection parses"); + let emitted = to_canonical_json(&surface).expect("canonical emit"); + assert_eq!(emitted, text, "rejection surface must round-trip byte-exactly"); +} + +#[test] +fn rejects_a_null_parameter_type() { + // Python's `Param.type: TypeRef` is not `TypeRef | None`; the emitter can + // never write `"type": null` on a parameter, so the typed model must not + // accept it either. + let text = r#"{ + "lowered_version": 1, + "module": "m", + "resources": [], + "externs": [], + "lifetimes": [], + "functions": [ + { + "name": "f", + "lifetime": null, + "params": [ + { + "handle": "parg_0", + "type": null, + "line": 1, + "lifetime": null + } + ], + "ret": null, + "body": [] + } + ], + "handles": [] +}"#; + parse_document(text) + .expect_err("a parameter with \"type\": null is a shape Python cannot produce"); +} + +#[test] +fn rejects_explicit_null_on_a_non_nullable_handle_key() { + // `released` is optional-but-boolean on the record; an explicit null must + // fail the parse, not decay to "missing" and vanish on re-emit. + let text = doc(1, r#"[{"handle": "sub_0", "released": null}]"#); + parse_document(&text).expect_err("\"released\": null must be rejected, not deleted"); +} + +#[test] +fn preserves_explicit_null_metadata_through_a_round_trip() { + // The strict OwnIR schema accepts (and the emitter preserves, by key + // membership) explicit `null` for `type`, `source`, and `source_type` — + // `{}` and `{"source_type": null}` are DIFFERENT Layer 2 documents. + let text = doc( + 1, + r#"[ + { + "handle": "sub_0", + "source": null, + "source_type": null, + "type": null + }, + { + "handle": "sub_1" + } + ]"#, + ); + let surface = parse_document(&text).expect("explicit-null metadata parses"); + let emitted = to_canonical_json(&surface).expect("canonical emit"); + for key in ["source", "source_type", "type"] { + assert_eq!( + emitted.matches(&format!("\"{key}\": null")).count(), + 1, + "explicit \"{key}\": null must survive the round trip exactly once \ + (present on sub_0, absent on sub_1); emitted:\n{emitted}" + ); + } +} diff --git a/tests/fixtures/lowered/handles_null_metadata.facts.json b/tests/fixtures/lowered/handles_null_metadata.facts.json new file mode 100644 index 00000000..e09d2e52 --- /dev/null +++ b/tests/fixtures/lowered/handles_null_metadata.facts.json @@ -0,0 +1,27 @@ +{ + "ownir_version": 0, + "module": "NullMeta", + "components": [ + { + "name": "C", + "file": "C.cs", + "subscriptions": [ + { + "event": "bus.Changed", + "handler": "OnChanged", + "line": 4, + "resource": "subscription", + "type": null, + "source": null, + "source_type": null + }, + { + "event": "bus.Closed", + "handler": "OnClosed", + "line": 9, + "resource": "subscription" + } + ] + } + ] +} diff --git a/tests/fixtures/lowered/handles_null_metadata.golden.json b/tests/fixtures/lowered/handles_null_metadata.golden.json new file mode 100644 index 00000000..1ecac10c --- /dev/null +++ b/tests/fixtures/lowered/handles_null_metadata.golden.json @@ -0,0 +1,137 @@ +{ + "lowered_version": 1, + "module": "NullMeta", + "resources": [ + { + "name": "Subscription", + "kind": "subscription token", + "members": [ + { + "role": "acquire", + "name": "Subscribe" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "Timer", + "kind": "timer", + "members": [ + { + "role": "acquire", + "name": "Start" + }, + { + "role": "release", + "name": "Stop" + } + ] + }, + { + "name": "Disposable", + "kind": "disposable field", + "members": [ + { + "role": "acquire", + "name": "New" + }, + { + "role": "release", + "name": "Dispose" + } + ] + }, + { + "name": "PooledBuffer", + "kind": "pooled buffer", + "members": [ + { + "role": "acquire", + "name": "Rent" + }, + { + "role": "release", + "name": "Return" + } + ] + } + ], + "externs": [ + { + "name": "$consume", + "params": [ + { + "effect": "consume", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow", + "params": [ + { + "effect": "borrow", + "type": "Disposable" + } + ] + }, + { + "name": "$borrow_mut", + "params": [ + { + "effect": "borrow_mut", + "type": "Disposable" + } + ] + } + ], + "lifetimes": [], + "functions": [ + { + "name": "C", + "lifetime": null, + "params": [], + "ret": null, + "body": [ + { + "stmt": "acquire", + "handle": "sub_0", + "resource": "Subscription", + "line": 4 + }, + { + "stmt": "acquire", + "handle": "sub_1", + "resource": "Subscription", + "line": 9 + } + ] + } + ], + "handles": [ + { + "handle": "sub_0", + "component": "C", + "file": "C.cs", + "line": 4, + "event": "bus.Changed", + "handler": "OnChanged", + "resource": "subscription", + "source": null, + "source_type": null, + "type": null + }, + { + "handle": "sub_1", + "component": "C", + "file": "C.cs", + "line": 9, + "event": "bus.Closed", + "handler": "OnClosed", + "resource": "subscription" + } + ] +} diff --git a/tests/fixtures/lowered/manifest.json b/tests/fixtures/lowered/manifest.json index 5a9a97c0..1f0ddf0b 100644 --- a/tests/fixtures/lowered/manifest.json +++ b/tests/fixtures/lowered/manifest.json @@ -41,6 +41,14 @@ ], "rust_replay": true }, + { + "name": "handles_null_metadata", + "rules": [ + "BR-L2", + "BR-D2" + ], + "rust_replay": true + }, { "name": "hoist_neg_early_return", "rules": [ From 159f45cc93c2222e792b7135a4d718a483f31dca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 18:53:35 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(rust):=20green=20=E2=80=94=20presence-a?= =?UTF-8?q?ware=20model,=20version=20gate,=20non-null=20Param.type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Maybe (Missing / Null / Value) for the schema-nullable handle trio (type, source, source_type): explicit JSON null now survives a parse->emit round trip instead of collapsing into "missing" — the Python emitter distinguishes the two by key membership, so the typed model must too. * Every other optional handle key rejects a present null (deserialize_with = "present") rather than decaying it to absent and silently deleting it on re-emit. * Param.type is non-nullable (TypeShape, not Option): Python's AST declares TypeRef, never TypeRef | None, so "type": null is a shape the emitter cannot produce and the model no longer accepts. * parse_document enforces lowered_version == LOWERED_VERSION on BOTH surfaces (accepted and rejected) — the lockstep promise is now a parse error, not something only the manifest check notices. All 26 rust_replay cases (incl. the new handles_null_metadata fixture) replay byte-identically; ledger equality (unique manifest names == facts files == golden files) is enforced Rust-side. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01MdYFKUaygHz1T9H1qJ7BqK --- rust/crates/own-lowered/src/lib.rs | 4 +- rust/crates/own-lowered/src/model.rs | 184 +++++++++++++++++--- rust/crates/own-lowered/tests/strictness.rs | 5 +- 3 files changed, 164 insertions(+), 29 deletions(-) diff --git a/rust/crates/own-lowered/src/lib.rs b/rust/crates/own-lowered/src/lib.rs index 288aad4e..a63f0e2c 100644 --- a/rust/crates/own-lowered/src/lib.rs +++ b/rust/crates/own-lowered/src/lib.rs @@ -22,6 +22,6 @@ mod model; pub use model::{ parse_document, to_canonical_json, Extern, ExternParam, Function, HandleEntry, Lifetime, - LoweredDocument, Manifest, ManifestCase, Param, Rejected, Resource, ResourceMember, Stmt, - Surface, TypeShape, LOWERED_VERSION, + LoweredDocument, Manifest, ManifestCase, Maybe, Param, Rejected, Resource, ResourceMember, + Stmt, Surface, TypeShape, LOWERED_VERSION, }; diff --git a/rust/crates/own-lowered/src/model.rs b/rust/crates/own-lowered/src/model.rs index 4c51656c..e88a2e68 100644 --- a/rust/crates/own-lowered/src/model.rs +++ b/rust/crates/own-lowered/src/model.rs @@ -2,11 +2,14 @@ //! //! Field ORDER in every struct is normative: serde serializes declaration //! order, and the canonical emitter must reproduce `ownlang/lowered.py`'s -//! construction order byte-for-byte. Optional keys exist in exactly two +//! construction order byte-for-byte. Optional keys exist in exactly three //! flavours, mirroring the Python emitter: fields Python always writes -//! (possibly `null`) are `Option` WITHOUT skip; the handle-entry allowlist -//! keys Python writes only-when-present are `Option` with -//! `skip_serializing_if`. +//! (possibly `null`) are `Option` WITHOUT skip; handle-entry allowlist +//! keys Python writes only-when-present are skipped-when-absent, and split by +//! the schema's nullability — the nullable trio (`type`, `source`, +//! `source_type`) is [`Maybe`] (missing / explicit null / value), every +//! other key rejects an explicit `null` outright (`deserialize_with = +//! "present"`). use serde::{Deserialize, Serialize}; @@ -14,6 +17,63 @@ use serde::{Deserialize, Serialize}; /// `LOWERED_VERSION` and `manifest.json`'s `lowered_version`. pub const LOWERED_VERSION: u32 = 1; +/// Three-state presence for the schema-nullable handle keys. +/// +/// The keys in question are `type`, `source`, and `source_type`: the Python +/// emitter copies them by key MEMBERSHIP (`if k in rec`), so `{}` and +/// `{"source_type": null}` are different Layer 2 documents. `Option` +/// cannot carry that distinction — this can. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Maybe { + /// The key is absent from the document (and must stay absent on emit). + #[default] + Missing, + /// The key is present with an explicit JSON `null`. + Null, + /// The key is present with a value. + Value(T), +} + +impl Maybe { + /// `skip_serializing_if` guard: only a truly absent key is skipped. + #[must_use] + pub const fn is_missing(&self) -> bool { + matches!(self, Self::Missing) + } +} + +impl Serialize for Maybe { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Missing => Err(serde::ser::Error::custom( + "Maybe::Missing must be skipped by the field attribute, never serialized", + )), + Self::Null => serializer.serialize_none(), + Self::Value(v) => v.serialize(serializer), + } + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for Maybe { + fn deserialize>(deserializer: D) -> Result { + // Only called when the key IS present (absence takes `default`), so + // JSON null maps to `Null` and anything else must match `T`. + Option::::deserialize(deserializer).map(|o| o.map_or(Self::Null, Self::Value)) + } +} + +/// `deserialize_with` for optional-but-NON-nullable keys: the field takes +/// `default` when absent, and a PRESENT value must match `T` itself — an +/// explicit `null` is rejected instead of decaying to "missing" and being +/// silently deleted on re-emit. +fn present<'de, T, D>(deserializer: D) -> Result, D::Error> +where + T: Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + T::deserialize(deserializer).map(Some) +} + /// One parsed golden: either a full lowered document or a fail-loud rejection. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Surface { @@ -99,8 +159,11 @@ pub struct Function { #[serde(deny_unknown_fields)] pub struct Param { pub handle: String, + /// NON-nullable: Python's `Param.type` is `TypeRef`, never `TypeRef | + /// None` — the emitter always writes an object here (unlike `Function. + /// ret`, which genuinely carries `null` for a void body). #[serde(rename = "type")] - pub type_shape: Option, + pub type_shape: TypeShape, pub line: i64, pub lifetime: Option, } @@ -170,35 +233,84 @@ pub enum Stmt { /// One normalized handle-map entry: `handle` first, then the allowlist keys in /// fixed order, each present only when the underlying record carried it. +/// +/// Presence semantics mirror the Python emitter's key MEMBERSHIP copy: +/// * the schema-nullable keys (`type`, `source`, `source_type` — the strict +/// `OwnIR` door accepts and preserves `null` for them) are [`Maybe`], so an +/// explicit `null` survives a round trip instead of collapsing into +/// "missing"; +/// * every other optional key is non-nullable: absent takes the default, and +/// a present `null` is REJECTED (`deserialize_with = "present"`) rather +/// than accepted-then-silently-deleted on re-emit. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct HandleEntry { pub handle: String, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub component: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub file: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub line: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub event: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub handler: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub resource: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub released: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub source_type: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default, skip_serializing_if = "Maybe::is_missing")] + pub source: Maybe, + #[serde(default, skip_serializing_if = "Maybe::is_missing")] + pub source_type: Maybe, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub di_source_life: Option, - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub type_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "type", default, skip_serializing_if = "Maybe::is_missing")] + pub type_name: Maybe, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub ever_released: Option, - #[serde(skip_serializing_if = "Option::is_none")] + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] pub pool: Option, } @@ -227,15 +339,35 @@ pub struct ManifestCase { /// full [`LoweredDocument`] — unknown fields fail in both shapes, so a /// Python-side surface change cannot slip past the typed replay. /// +/// Both surfaces must carry `lowered_version ==` [`LOWERED_VERSION`]: the +/// version moves in lockstep on both sides of the contract, so a foreign +/// version is a parse error here — not something only the manifest check +/// happens to notice. +/// /// # Errors -/// Returns the underlying `serde_json` error when the text is not valid JSON -/// or does not match the closed Layer 2 shapes. +/// Returns the underlying `serde_json` error when the text is not valid JSON, +/// does not match the closed Layer 2 shapes, or carries a foreign +/// `lowered_version`. pub fn parse_document(text: &str) -> Result { let value: serde_json::Value = serde_json::from_str(text)?; - if value.get("error").is_some() { - return serde_json::from_value::(value).map(Surface::Rejected); + let surface = if value.get("error").is_some() { + Surface::Rejected(serde_json::from_value::(value)?) + } else { + Surface::Lowered(serde_json::from_value::(value)?) + }; + let version = match &surface { + Surface::Lowered(doc) => doc.lowered_version, + Surface::Rejected(rej) => rej.lowered_version, + }; + if version == LOWERED_VERSION { + Ok(surface) + } else { + Err(serde::de::Error::custom(format!( + "document lowered_version {version} does not match this crate's \ + LOWERED_VERSION {LOWERED_VERSION} — the Layer 2 surface moves in \ + lockstep on both sides" + ))) } - serde_json::from_value::(value).map(Surface::Lowered) } /// The canonical serialized form — byte-identical to the Python emitter's diff --git a/rust/crates/own-lowered/tests/strictness.rs b/rust/crates/own-lowered/tests/strictness.rs index 54f65527..61290902 100644 --- a/rust/crates/own-lowered/tests/strictness.rs +++ b/rust/crates/own-lowered/tests/strictness.rs @@ -59,7 +59,10 @@ fn accepts_the_current_version_on_a_rejection_document() { let text = "{\n \"lowered_version\": 1,\n \"error\": \"boom\"\n}\n"; let surface = parse_document(text).expect("a current-version rejection parses"); let emitted = to_canonical_json(&surface).expect("canonical emit"); - assert_eq!(emitted, text, "rejection surface must round-trip byte-exactly"); + assert_eq!( + emitted, text, + "rejection surface must round-trip byte-exactly" + ); } #[test]