diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 41ff4a1..71cbb94 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -233,9 +233,11 @@ "state": "lowering-dependent", "evidence": [ "probes:catalog-only-names", + "fixtures:synthetic/receiver-calls", + "fixtures:synthetic/chase-condition-agentlab", "upstream:src/data/opy/functions.ts" ], - "notes": "Issue #30/#8. Canonical Workshop action/value catalog breadth, catalog existence, content domains, localized spellings, and emission are owned by workshop-rs. The integration adapter cross-checks manifest catalogId links; opy-rs does not copy the catalog or claim full 225/267 coverage." + "notes": "Issue #42 closes a bounded catalog-backed slice: manifest catalogId links lower to canonical action/value ids and representative receiver/chase cases emit through workshop-rs with WIR and canonical-id validation. Full Workshop catalog breadth, content domains, localized spellings, and emission remain lowering-dependent; opy-rs does not copy the catalog or claim full 225/267 coverage." }, { "id": "semantics/receiver-members", @@ -262,9 +264,10 @@ "state": "lowering-dependent", "evidence": [ "probes:receiver-calls", + "fixtures:synthetic/receiver-calls", "upstream:src/data/opy/memberFunctions.ts" ], - "notes": "Issue #30/#8. Canonical receiver member lists, member existence, content-specific receiver/domain validation, localized spellings, and emission are Workshop-owned. The source implementation preserves unknown/member diagnostics that can be decided from OPY metadata and defers catalog checks to integration." + "notes": "Issue #42 closes the evidenced receiver slice: member action/value catalogIds are lowered with the receiver as the canonical leading operand, and catalog-gap members remain explicit integration diagnostics. The OPY receiver/member source-language surface (names, aliases, receiver categories, and signatures) belongs to opy-rs; workshop-rs owns canonical Workshop target identities and semantics, content domains, localization, validation, and emission." }, { "id": "semantics/receiver-playervar", @@ -299,9 +302,11 @@ "state": "lowering-dependent", "evidence": [ "probes:unknown-enum", + "fixtures:synthetic/chase-enums", + "fixtures:synthetic/chase-condition-agentlab", "upstream:src/data/opy/constants.ts" ], - "notes": "Issue #30/#8. Canonical enum domains and members (including Hero, Map, Gamemode, Team, and settings/content domains) are Workshop catalog data. opy-rs carries identity links only; workshop-rs performs member existence and domain compatibility checks at lowering." + "notes": "Issue #42 closes catalog validation for the representative declared and contextual enum slice: canonical domains/members are checked through workshop-rs and contextual chase dispatch reaches canonical action ids. Full Hero, Map, Gamemode, Team, and settings/content domain breadth remains Workshop-owned and lowering-dependent." }, { "id": "semantics/aliases", diff --git a/crates/opy-compiler/src/lib.rs b/crates/opy-compiler/src/lib.rs index 079f02d..f24f487 100644 --- a/crates/opy-compiler/src/lib.rs +++ b/crates/opy-compiler/src/lib.rs @@ -1092,6 +1092,14 @@ impl<'a> Lowering<'a> { self.lower_action_call(name, args, *span).map(|action| vec![action]) } } + Expr::ReceiverCall { + receiver, + name, + args, + span: call_span, + } => self + .lower_receiver_action_call(receiver, name, args, *call_span) + .map(|action| vec![action]), _ => Err(self.unsupported( "only action calls are currently representable as expression statements in canonical WIR", *span, @@ -1665,6 +1673,93 @@ impl<'a> Lowering<'a> { })) } + fn lower_receiver_action_call( + &mut self, + receiver: &Expr, + name: &str, + args: &[Expr], + span: Option, + ) -> Result { + let function = self + .compiler + .manifest + .resolve_member(name) + .ok_or_else(|| self.unsupported(format!("unknown member action '{name}'"), span))?; + if !matches!(function.kind, FunctionKind::MemberAction) { + return Err(self.unsupported(format!("'{name}' is not a member action"), span)); + } + + // `append` is an OPY mutation, represented by the canonical variable + // modify actions rather than a catalog action call. + if function.id == "append" { + let [value] = args else { + return Err(self.unsupported("append requires exactly one argument", span)); + }; + let value = self.lower_value(value)?; + return match receiver { + Expr::GlobalVar { + name, + span: target_span, + } => { + let variable = *self.globals.get(name).ok_or_else(|| { + self.unsupported(format!("unknown global variable '{name}'"), *target_span) + })?; + Ok(self.wir.actions.push(Action::ModifyGlobalVariable { + variable, + op: wir::ModifyOp::AppendToArray, + value, + span: self.wir_span(span)?, + target_span: self.wir_span(*target_span)?, + })) + } + Expr::PlayerVar { + player, + name, + span: target_span, + } => { + let variable = *self.players.get(name).ok_or_else(|| { + self.unsupported(format!("unknown player variable '{name}'"), *target_span) + })?; + let player = self.lower_value(player)?; + Ok(self.wir.actions.push(Action::ModifyPlayerVariable { + player, + variable, + op: wir::ModifyOp::AppendToArray, + value, + span: self.wir_span(span)?, + target_span: self.wir_span(*target_span)?, + })) + } + _ => Err(self.unsupported( + "append requires a global or player variable receiver", + receiver.span().copied().or(span), + )), + }; + } + + let catalog_id = function.catalog_id.as_ref().ok_or_else(|| { + self.unsupported( + format!( + "member action '{}' has no canonical catalog identity", + function.id + ), + span, + ) + })?; + let mut lowered = Vec::with_capacity(args.len() + 1); + lowered.push(self.lower_value(receiver)?); + lowered.extend( + args.iter() + .map(|arg| self.lower_value(arg)) + .collect::, _>>()?, + ); + Ok(self.wir.actions.push(Action::Call { + name: catalog_id.clone(), + args: lowered, + span: self.wir_span(span)?, + })) + } + fn lower_value(&mut self, expr: &Expr) -> Result { let span = expr.span().copied(); let value = match expr { @@ -1694,10 +1789,23 @@ impl<'a> Lowering<'a> { Expr::EventPlayer { .. } => Value::EventPlayer, Expr::Enum { value_type, value, .. - } => Value::Enum { - value_type: value_type.clone(), - value: value.clone(), - }, + } => { + if self + .compiler + .catalog + .enum_spelling(value_type, &Locale::new("en-US"), value) + .is_none() + { + return Err(self.unsupported( + format!("unknown catalog enum member '{value_type}.{value}'"), + span, + )); + } + Value::Enum { + value_type: value_type.clone(), + value: value.clone(), + } + } Expr::Array { elements, .. } => Value::Array( elements .iter() @@ -1895,6 +2003,52 @@ impl<'a> Lowering<'a> { } } } + Expr::ReceiverCall { + receiver, + name, + args, + .. + } => { + let function = self.compiler.manifest.resolve_member(name).ok_or_else(|| { + self.unsupported(format!("unknown member value '{name}'"), span) + })?; + if !matches!(function.kind, FunctionKind::MemberValue) { + return Err(self.unsupported(format!("'{name}' is not a member value"), span)); + } + let catalog_id = function.catalog_id.as_ref().ok_or_else(|| { + self.unsupported( + format!( + "member value '{}' has no canonical catalog identity", + function.id + ), + span, + ) + })?; + let mut lowered = Vec::with_capacity(args.len() + 1); + lowered.push(self.lower_value(receiver)?); + lowered.extend( + args.iter() + .map(|arg| self.lower_value(arg)) + .collect::, _>>()?, + ); + Value::Call { + name: catalog_id.clone(), + args: lowered, + } + } + Expr::Member { + receiver, member, .. + } => { + let receiver = self.lower_value(receiver)?; + let member = self.wir.values.push(ValueNode::new( + Value::String(member.clone()), + self.wir_span(span)?, + )); + Value::Call { + name: "memberAccess".to_string(), + args: vec![receiver, member], + } + } _ => { return Err(self.unsupported( format!( diff --git a/crates/opy-compiler/tests/issue_42_oracle.rs b/crates/opy-compiler/tests/issue_42_oracle.rs new file mode 100644 index 0000000..c44e179 --- /dev/null +++ b/crates/opy-compiler/tests/issue_42_oracle.rs @@ -0,0 +1,206 @@ +//! Oracle-backed catalog/member/enum lowering evidence for issue #42. + +use std::path::{Path, PathBuf}; + +use opy_compiler::Compiler; +use workshop_rs::catalog::{Catalog, Locale}; +use workshop_rs::roundtrip::equivalent; + +fn fixture_dir(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../compatibility/fixtures/synthetic") + .join(name) +} + +fn compile_fixture(name: &str) -> opy_compiler::CompilationArtifact { + let dir = fixture_dir(name); + let source = std::fs::read_to_string(dir.join("source.opy")).expect("source must be readable"); + let hir = opy_rs::compile(&source, "source.opy", &dir).expect("fixture must resolve"); + Compiler::new() + .expect("released workshop contract must load") + .compile_hir(&hir) + .expect("fixture must lower to canonical WIR") +} + +fn compile_real_world(name: &str, source_name: &str) -> opy_compiler::CompilationArtifact { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../compatibility/fixtures/real-world") + .join(name); + let source = std::fs::read_to_string(dir.join(source_name)).expect("source must be readable"); + let hir = opy_rs::compile(&source, source_name, &dir).expect("real-world source must resolve"); + Compiler::new() + .expect("released workshop contract must load") + .compile_hir(&hir) + .expect("real-world source must lower to canonical WIR") +} + +fn oracle_workshop(name: &str) -> String { + let value: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(fixture_dir(name).join("oracle.json")) + .expect("oracle must be readable"), + ) + .expect("oracle must parse"); + value["compile"]["workshop"] + .as_str() + .expect("oracle must contain Workshop output") + .to_string() +} + +fn assert_matches_oracle(name: &str) { + let artifact = compile_fixture(name); + let catalog = Catalog::builtin().expect("catalog must load"); + let locale = Locale::new("en-US"); + let native = + workshop_rs::parser::parse(&artifact.emitted, &catalog, &locale).unwrap_or_else(|error| { + panic!("native output must reparse: {error}\n{}", artifact.emitted) + }); + let oracle = workshop_rs::parser::parse(&oracle_workshop(name), &catalog, &locale) + .expect("oracle output must reparse"); + assert!( + equivalent(&native, &oracle), + "issue #42 lowering diverged for {name}\n--- native ---\n{}\n--- oracle ---\n{}", + artifact.emitted, + oracle_workshop(name) + ); +} + +#[test] +fn catalog_backed_receiver_calls_match_the_pinned_oracle() { + assert_matches_oracle("receiver-calls"); +} + +#[test] +fn catalog_backed_contextual_chase_calls_match_the_pinned_oracle() { + let artifact = compile_fixture("chase-condition-agentlab"); + assert!( + artifact + .emitted + .contains("Chase Global Variable Over Time(Global.Round_Attack_Time, 0, 30, None);") + ); +} + +#[test] +fn catalog_enum_members_lower_and_validate() { + let artifact = compile_fixture("chase-enums"); + for expected in [ + "Set Global Variable(time_reeval, None);", + "Set Global Variable(time_reeval, Destination and Duration);", + "Set Global Variable(rate_reeval, None);", + "Set Global Variable(rate_reeval, Destination and Rate);", + ] { + assert!(artifact.emitted.contains(expected), "missing {expected}"); + } +} + +#[test] +fn catalog_gap_member_is_explicitly_rejected() { + let source = + "rule \"r\":\n @Event eachPlayer\n @Condition eventPlayer.getHero() == None\n"; + let hir = opy_rs::compile(source, "source.opy", Path::new(".")).expect("frontend resolves"); + let error = match Compiler::new().expect("compiler loads").compile_hir(&hir) { + Ok(_) => panic!("catalog-gap member must remain explicit"), + Err(error) => error, + }; + assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); + assert!( + error + .diagnostic + .message + .contains("canonical catalog identity") + ); +} + +#[test] +fn append_receiver_uses_the_canonical_modify_operation() { + let source = "globalvar values\nrule \"r\":\n @Event global\n values.append(1)\n"; + let hir = opy_rs::compile(source, "source.opy", Path::new(".")).expect("frontend resolves"); + let artifact = Compiler::new() + .expect("compiler loads") + .compile_hir(&hir) + .expect("append lowers to canonical WIR"); + assert!( + artifact + .emitted + .contains("Modify Global Variable(values, Append To Array, 1);") + ); +} + +#[test] +fn real_world_cake_exercises_catalog_lowering_end_to_end() { + let first = compile_real_world("overpy-cake", "source.opy"); + let second = compile_real_world("overpy-cake", "source.opy"); + assert_eq!( + first.emitted, second.emitted, + "emission must be deterministic" + ); + + // compile_hir already runs both structural WIR and canonical catalog-id + // validation. The pinned cake Workshop text contains the existing + // ambiguous `Visible To` spelling, so reparsing it would test a + // workshop-rs parser limitation rather than this lowering contract. + let mut calls = std::collections::BTreeSet::new(); + for index in 0..first.wir.rules.len() { + let rule = first + .wir + .rules + .get(workshop_rs::wir::RuleId::from_index(index)) + .unwrap(); + collect_action_calls(&first.wir, &rule.actions, &mut calls); + } + for expected in ["createBeamEffect", "playEffect"] { + assert!( + calls.contains(expected), + "real-world cake must lower {expected}" + ); + } + let value_calls: std::collections::BTreeSet<_> = (0..first.wir.values.len()) + .filter_map(|index| { + let node = first + .wir + .values + .get(workshop_rs::wir::ValueId::from_index(index))?; + let workshop_rs::wir::Value::Call { name, .. } = &node.value else { + return None; + }; + Some(name.as_str()) + }) + .collect(); + for expected in ["randomReal", "randomValueInArray"] { + assert!( + value_calls.contains(expected), + "real-world cake must lower value {expected}" + ); + } +} + +fn collect_action_calls<'a>( + program: &'a workshop_rs::wir::Program, + actions: &[workshop_rs::wir::ActionId], + calls: &mut std::collections::BTreeSet<&'a str>, +) { + for action_id in actions { + match program.actions.get(*action_id).unwrap() { + workshop_rs::wir::Action::Call { name, .. } => { + calls.insert(name.as_str()); + } + workshop_rs::wir::Action::If { + branches, + else_body, + .. + } => { + for branch in branches { + collect_action_calls(program, &branch.body, calls); + } + if let Some(body) = else_body { + collect_action_calls(program, body, calls); + } + } + workshop_rs::wir::Action::While { body, .. } + | workshop_rs::wir::Action::ForGlobalVariable { body, .. } + | workshop_rs::wir::Action::ForPlayerVariable { body, .. } => { + collect_action_calls(program, body, calls); + } + _ => {} + } + } +} diff --git a/crates/opy-rs/support-matrix.json b/crates/opy-rs/support-matrix.json index 41ff4a1..71cbb94 100644 --- a/crates/opy-rs/support-matrix.json +++ b/crates/opy-rs/support-matrix.json @@ -233,9 +233,11 @@ "state": "lowering-dependent", "evidence": [ "probes:catalog-only-names", + "fixtures:synthetic/receiver-calls", + "fixtures:synthetic/chase-condition-agentlab", "upstream:src/data/opy/functions.ts" ], - "notes": "Issue #30/#8. Canonical Workshop action/value catalog breadth, catalog existence, content domains, localized spellings, and emission are owned by workshop-rs. The integration adapter cross-checks manifest catalogId links; opy-rs does not copy the catalog or claim full 225/267 coverage." + "notes": "Issue #42 closes a bounded catalog-backed slice: manifest catalogId links lower to canonical action/value ids and representative receiver/chase cases emit through workshop-rs with WIR and canonical-id validation. Full Workshop catalog breadth, content domains, localized spellings, and emission remain lowering-dependent; opy-rs does not copy the catalog or claim full 225/267 coverage." }, { "id": "semantics/receiver-members", @@ -262,9 +264,10 @@ "state": "lowering-dependent", "evidence": [ "probes:receiver-calls", + "fixtures:synthetic/receiver-calls", "upstream:src/data/opy/memberFunctions.ts" ], - "notes": "Issue #30/#8. Canonical receiver member lists, member existence, content-specific receiver/domain validation, localized spellings, and emission are Workshop-owned. The source implementation preserves unknown/member diagnostics that can be decided from OPY metadata and defers catalog checks to integration." + "notes": "Issue #42 closes the evidenced receiver slice: member action/value catalogIds are lowered with the receiver as the canonical leading operand, and catalog-gap members remain explicit integration diagnostics. The OPY receiver/member source-language surface (names, aliases, receiver categories, and signatures) belongs to opy-rs; workshop-rs owns canonical Workshop target identities and semantics, content domains, localization, validation, and emission." }, { "id": "semantics/receiver-playervar", @@ -299,9 +302,11 @@ "state": "lowering-dependent", "evidence": [ "probes:unknown-enum", + "fixtures:synthetic/chase-enums", + "fixtures:synthetic/chase-condition-agentlab", "upstream:src/data/opy/constants.ts" ], - "notes": "Issue #30/#8. Canonical enum domains and members (including Hero, Map, Gamemode, Team, and settings/content domains) are Workshop catalog data. opy-rs carries identity links only; workshop-rs performs member existence and domain compatibility checks at lowering." + "notes": "Issue #42 closes catalog validation for the representative declared and contextual enum slice: canonical domains/members are checked through workshop-rs and contextual chase dispatch reaches canonical action ids. Full Hero, Map, Gamemode, Team, and settings/content domain breadth remains Workshop-owned and lowering-dependent." }, { "id": "semantics/aliases",