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..a63f0e2c --- /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, 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 new file mode 100644 index 00000000..e88a2e68 --- /dev/null +++ b/rust/crates/own-lowered/src/model.rs @@ -0,0 +1,387 @@ +//! 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 three +//! flavours, mirroring the Python emitter: fields Python always writes +//! (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}; + +/// 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; + +/// 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 { + /// 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, + /// 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: TypeShape, + 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. +/// +/// 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( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub component: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub file: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub line: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub event: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub handler: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub resource: Option, + #[serde( + default, + deserialize_with = "present", + skip_serializing_if = "Option::is_none" + )] + pub released: Option, + #[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", 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( + default, + deserialize_with = "present", + 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. +/// +/// 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, +/// 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)?; + 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" + ))) + } +} + +/// 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..b43345c0 --- /dev/null +++ b/rust/crates/own-lowered/tests/replay.rs @@ -0,0 +1,121 @@ +//! 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 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"), + "/../../../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"); + + // 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 { + let golden = read(&format!("{}.golden.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" + ); +} diff --git a/rust/crates/own-lowered/tests/strictness.rs b/rust/crates/own-lowered/tests/strictness.rs new file mode 100644 index 00000000..61290902 --- /dev/null +++ b/rust/crates/own-lowered/tests/strictness.rs @@ -0,0 +1,138 @@ +//! 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": [