From f40862b74d6d6a97be334fecc88ac550855d0c62 Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 14:54:12 +0800 Subject: [PATCH 1/3] feat(opy-compiler): lower OPY structure into canonical WIR Implement deterministic declaration and subroutine allocation, structural rule and event lowering, source provenance, and explicit diagnostics for unsupported WIR surfaces. Fixes #40 --- compatibility/support-matrix.json | 20 +- crates/opy-compiler/src/lib.rs | 670 +++++++++++++++++++++++++++--- docs/opy/support-matrix.md | 11 +- 3 files changed, 645 insertions(+), 56 deletions(-) diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 808137d..7481900 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -9,7 +9,7 @@ }, "snapshot": { "date": "2026-08-18", - "note": "Readiness baseline for #7 through the #28/#29/#30/#33 Draft PR series plus the bounded #35 integration slice. Frontend-supported rows include the pinned OPY syntax, directives, preprocessing, macro statements, rule directives/model, JavaScript macros, and runtime hooks. Semantic-supported rows include declaration resolution, for-loop binders, modules, keyword arguments, the declared alias surface, and the OPY-owned manifest overlay for builtin/member/enum semantics. Canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. The 41-fixture differential corpus currently reports 34 matches, 7 explicit known gaps, 0 unexpected divergences, and 0 inconclusive results. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", + "note": "Readiness baseline for #7 through the #28/#29/#30/#33 Draft PR series plus the bounded #35 and #40 integration slices. Frontend-supported rows include the pinned OPY syntax, directives, preprocessing, macro statements, rule directives/model, JavaScript macros, and runtime hooks. Semantic-supported rows include declaration resolution, for-loop binders, modules, keyword arguments, the declared alias surface, and the OPY-owned manifest overlay for builtin/member/enum semantics. Canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. The 41-fixture differential corpus currently reports 34 matches, 7 explicit known gaps, 0 unexpected divergences, and 0 inconclusive results. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", "asOfCommit": "4d88daf4e3445af37ce06774d789a94c5bcbe355" }, "states": { @@ -613,6 +613,18 @@ ], "notes": "Issue #35. The dedicated opy-compiler crate consumes resolved OPY HIR, preserves source files/spans in workshop-rs WIR, cross-checks manifest catalogId/domain links against Catalog, validates canonical WIR, and emits deterministic en-US Workshop through the published workshop-rs v0.1.1 contract. This is a bounded first slice; broader OPY lowering remains lowering-dependent and is not reclassified by this fixture." }, + { + "id": "compilation/opy-structural-lowering", + "name": "OPY structural HIR -> canonical WIR lowering: declarations, subroutines, rules, events, and provenance", + "category": "compilation", + "state": "end-to-end-supported", + "evidence": [ + "fixtures:synthetic/issue-35-integration", + "test:opy-compiler-structural-lowering", + "contract:workshop-rs-v0.1.1" + ], + "notes": "Issue #40. Global/player variables and subroutines use deterministic canonical indices with source/name spans; subroutine definitions and calls, rule disabled state, conditions, supported event/filter identities, and source-file provenance lower directly into workshop-rs WIR. Unsupported settings, declarations, annotations, events, filters, and statement forms remain explicit source-attributed integration diagnostics. This does not claim full statement/expression, catalog/member/enum, settings, locale, optimizer, hook, or decompilation coverage." + }, { "id": "compilation/workshop-lowering", "name": "HIR -> Workshop lowering and emission through workshop-rs", @@ -622,7 +634,7 @@ "fixtures:synthetic", "fixtures:real-world" ], - "notes": "Issue #8 remains the broader lowering track. The released workshop-rs v0.1.1 public contract now supports the bounded #35 adapter; full OPY surface lowering, settings/content, locales, optimizer effects, and hooks remain Workshop-owned follow-up work. Oracle snapshots (fixtures/**/oracle.json) remain preserved reference evidence; no temporary Workshop IR is introduced here." + "notes": "Issue #8 remains the broader lowering track. The released workshop-rs v0.1.1 public contract now supports the bounded #35 adapter and the #40 structural skeleton; full OPY statement/expression lowering, catalog/member/enum breadth, settings/content, locales, optimizer effects, and hooks remain lowering-dependent follow-up work. Oracle snapshots (fixtures/**/oracle.json) remain preserved reference evidence; no temporary Workshop IR is introduced here." }, { "id": "compilation/end-to-end", @@ -664,7 +676,7 @@ "frontend-supported": 23, "semantic-supported": 13, "lowering-dependent": 13, - "end-to-end-supported": 1 + "end-to-end-supported": 2 }, "byCategory": { "syntax": 14, @@ -675,7 +687,7 @@ "translations": 2, "optimization": 3, "runtime": 1, - "compilation": 4, + "compilation": 5, "decompilation": 2 } } diff --git a/crates/opy-compiler/src/lib.rs b/crates/opy-compiler/src/lib.rs index 562d636..576154b 100644 --- a/crates/opy-compiler/src/lib.rs +++ b/crates/opy-compiler/src/lib.rs @@ -3,17 +3,17 @@ //! `opy-frontend` remains a standalone OPY/HIR producer. This crate is the //! consumer-owned compiler layer: it pins the released `workshop-rs` v0.1.1 //! contract, checks the OPY manifest links against the canonical catalog, and -//! lowers the small validated vertical slice into canonical WIR before +//! lowers the supported OPY program structure into canonical WIR before //! validation and deterministic Workshop emission. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use opy_frontend::hir::{self, Expr, RuleEntry, Span as HirSpan, Stmt}; use opy_frontend::manifest::{FunctionKind, Manifest}; use workshop_rs::catalog::{Catalog, CatalogIdentity, Kind, Locale}; use workshop_rs::source::{Position as WorkshopPosition, SourceFile, Span as WorkshopSpan}; -use workshop_rs::wir::{self, Action, Event, Program, Value, ValueNode}; +use workshop_rs::wir::{self, Action, Event, PlayerEventKind, Program, Value, ValueNode}; /// The exact released dependency contract consumed by this crate. pub const WORKSHOP_RS_VERSION: &str = "0.1.1"; @@ -217,7 +217,8 @@ impl Compiler { /// against the canonical catalog, and emit deterministic en-US Workshop. pub fn compile_hir(&self, hir: &hir::Program) -> Result { let mut lowering = Lowering::new(self, hir)?; - lowering.copy_files(); + lowering.copy_files()?; + lowering.reject_unsupported_metadata()?; lowering.lower_declarations()?; lowering.lower_rules()?; @@ -267,6 +268,8 @@ struct Lowering<'a> { wir_to_hir_files: Vec, globals: HashMap, players: HashMap, + subroutines: HashMap, + defined_subroutines: HashSet, } impl<'a> Lowering<'a> { @@ -279,71 +282,165 @@ impl<'a> Lowering<'a> { wir_to_hir_files: Vec::new(), globals: HashMap::new(), players: HashMap::new(), + subroutines: HashMap::new(), + defined_subroutines: HashSet::new(), }) } - fn copy_files(&mut self) { + fn copy_files(&mut self) -> Result<(), IntegrationError> { for file in &self.hir.files { + if self.files.contains_key(&file.id) { + return Err(IntegrationError::new( + "source-file", + format!("duplicate HIR source file id {}", file.id), + None, + )); + } let id = self.wir.files.push(SourceFile::new(file.path.clone())); self.files.insert(file.id, id); self.wir_to_hir_files.push(file.id); } + Ok(()) + } + + fn reject_unsupported_metadata(&self) -> Result<(), IntegrationError> { + if let Some(settings) = &self.hir.settings { + return Err(self.unsupported( + "custom-game settings lowering is outside #40", + settings.span, + )); + } + Ok(()) } fn lower_declarations(&mut self) -> Result<(), IntegrationError> { - let mut next_global = 0; - let mut next_player = 0; + let globals = self + .hir + .declarations + .iter() + .filter_map(|declaration| match declaration { + hir::Declaration::GlobalVariable { index, span, .. } => Some((*index, *span)), + _ => None, + }) + .collect::>(); + let players = self + .hir + .declarations + .iter() + .filter_map(|declaration| match declaration { + hir::Declaration::PlayerVariable { index, span, .. } => Some((*index, *span)), + _ => None, + }) + .collect::>(); + let subroutines = self + .hir + .declarations + .iter() + .filter_map(|declaration| match declaration { + hir::Declaration::Subroutine { index, span, .. } => Some((*index, *span)), + _ => None, + }) + .collect::>(); + let global_indices = allocate_indices(&globals, "global variable")?; + let player_indices = allocate_indices(&players, "player variable")?; + let subroutine_indices = allocate_indices(&subroutines, "subroutine")?; + let mut global_index = 0; + let mut player_index = 0; + let mut subroutine_index = 0; for declaration in &self.hir.declarations { match declaration { hir::Declaration::GlobalVariable { name, - index, + index: _, span, name_span, initializer, } => { if initializer.is_some() { - return Err(self.unsupported( - "global variable initializers are outside the #35 vertical slice", - *span, - )); + return Err( + self.unsupported("global variable initializers are outside #40", *span) + ); } - let assigned = index.unwrap_or(next_global); - next_global = next_global.max(assigned.saturating_add(1)); + let assigned = global_indices[global_index]; + global_index += 1; let id = self.wir.global_variables.push(wir::WorkshopVariable { name: name.clone(), index: assigned, span: self.wir_span(*span)?, name_span: self.wir_span(*name_span)?, }); - self.globals.insert(name.clone(), id); + if self.globals.insert(name.clone(), id).is_some() { + return Err(IntegrationError::new( + "symbol-collision", + format!("duplicate global variable '{name}'"), + *span, + )); + } } hir::Declaration::PlayerVariable { name, - index, + index: _, span, name_span, initializer, } => { if initializer.is_some() { - return Err(self.unsupported( - "player variable initializers are outside the #35 vertical slice", + return Err( + self.unsupported("player variable initializers are outside #40", *span) + ); + } + let assigned = player_indices[player_index]; + player_index += 1; + let id = self.wir.player_variables.push(wir::WorkshopVariable { + name: name.clone(), + index: assigned, + span: self.wir_span(*span)?, + name_span: self.wir_span(*name_span)?, + }); + if self.players.insert(name.clone(), id).is_some() { + return Err(IntegrationError::new( + "symbol-collision", + format!("duplicate player variable '{name}'"), *span, )); } - let assigned = index.unwrap_or(next_player); - next_player = next_player.max(assigned.saturating_add(1)); - let id = self.wir.player_variables.push(wir::WorkshopVariable { + } + hir::Declaration::Subroutine { + name, + span, + name_span, + .. + } => { + let assigned = subroutine_indices[subroutine_index]; + subroutine_index += 1; + let id = self.wir.subroutines.push(wir::WorkshopSubroutine { name: name.clone(), index: assigned, span: self.wir_span(*span)?, name_span: self.wir_span(*name_span)?, }); - self.players.insert(name.clone(), id); + if self.subroutines.insert(name.clone(), id).is_some() { + return Err(IntegrationError::new( + "symbol-collision", + format!("duplicate subroutine '{name}'"), + *span, + )); + } + } + hir::Declaration::Constant { name, span, .. } => { + return Err(self.unsupported( + format!( + "constant declaration '{name}' is not representable in canonical WIR" + ), + *span, + )); + } + hir::Declaration::Macro { name, span, .. } => { + return Err(self.unsupported( + format!("macro declaration '{name}' is not representable in canonical WIR"), + *span, + )); } - hir::Declaration::Subroutine { .. } - | hir::Declaration::Constant { .. } - | hir::Declaration::Macro { .. } => {} } } Ok(()) @@ -353,11 +450,16 @@ impl<'a> Lowering<'a> { for entry in &self.hir.rules { match entry { RuleEntry::Rule(rule) => self.lower_rule(rule)?, - RuleEntry::SubroutineDef { span, .. } => { - return Err(self.unsupported( - "subroutine rule lowering is outside the #35 vertical slice", - *span, - )); + RuleEntry::SubroutineDef { + name, + source_name, + span, + name_span, + body, + annotations, + .. + } => { + self.lower_subroutine(name, source_name, *span, *name_span, body, annotations)? } } } @@ -365,19 +467,8 @@ impl<'a> Lowering<'a> { } fn lower_rule(&mut self, rule: &hir::Rule) -> Result<(), IntegrationError> { - let event = match rule.event.name.as_str() { - "global" => Event::Global, - "eachPlayer" => Event::EachPlayer, - _ => { - return Err(self.unsupported( - format!( - "event '{}' is outside the #35 vertical slice", - rule.event.name - ), - rule.event.span, - )); - } - }; + self.reject_rule_metadata(rule)?; + let event = self.lower_event(&rule.event, &rule.annotations)?; let conditions = rule .conditions .iter() @@ -400,6 +491,310 @@ impl<'a> Lowering<'a> { Ok(()) } + fn lower_subroutine( + &mut self, + name: &str, + source_name: &str, + span: Option, + name_span: Option, + body: &[Stmt], + annotations: &[hir::Annotation], + ) -> Result<(), IntegrationError> { + self.reject_subroutine_metadata(annotations)?; + let source_name = if source_name.is_empty() { + name + } else { + source_name + }; + let subroutine = *self.subroutines.get(source_name).ok_or_else(|| { + self.unsupported( + format!("subroutine definition '{source_name}' has no declaration"), + name_span.or(span), + ) + })?; + if !self.defined_subroutines.insert(subroutine) { + return Err(self.unsupported( + format!("subroutine '{source_name}' has multiple definitions"), + name_span.or(span), + )); + } + if let Some(declaration) = self.wir.subroutines.get_mut(subroutine) { + declaration.name = name.to_string(); + } + if let Some(existing) = self.subroutines.get(name) { + if *existing != subroutine { + return Err(self.unsupported( + format!( + "subroutine presentation name '{name}' collides with another subroutine" + ), + name_span.or(span), + )); + } + } else { + self.subroutines.insert(name.to_string(), subroutine); + } + let actions = body + .iter() + .map(|stmt| self.lower_action(stmt)) + .collect::, _>>()?; + self.wir.rules.push(wir::Rule { + name: format!("Subroutine {name}"), + span: self.wir_span(span)?, + name_span: self.wir_span(name_span)?, + disabled: false, + event: Event::Subroutine(subroutine), + conditions: Vec::new(), + actions, + }); + Ok(()) + } + + fn reject_rule_metadata(&self, rule: &hir::Rule) -> Result<(), IntegrationError> { + if rule.delimiter { + let span = rule + .annotations + .iter() + .find(|annotation| annotation.name == "Delimiter") + .and_then(|annotation| annotation.span) + .or(rule.span); + return Err(self.unsupported( + "rule delimiter metadata is not representable in canonical WIR", + span, + )); + } + if rule.new_page.is_some() { + let span = rule + .annotations + .iter() + .find(|annotation| annotation.name == "NewPage") + .and_then(|annotation| annotation.span) + .or(rule.span); + return Err(self.unsupported( + "rule new-page metadata is not representable in canonical WIR", + span, + )); + } + for annotation in &rule.annotations { + match annotation.name.as_str() { + "Event" | "Condition" | "Team" | "Slot" | "Hero" | "Disabled" => {} + _ => { + return Err(self.unsupported( + format!( + "rule annotation '{}' is not representable in canonical WIR", + annotation.name + ), + annotation.span.or(rule.span), + )); + } + } + } + Ok(()) + } + + fn reject_subroutine_metadata( + &self, + annotations: &[hir::Annotation], + ) -> Result<(), IntegrationError> { + for annotation in annotations { + match annotation.name.as_str() { + "Name" => {} + _ => { + return Err(self.unsupported( + format!( + "subroutine annotation '{}' is not representable in canonical WIR", + annotation.name + ), + annotation.span, + )); + } + } + } + Ok(()) + } + + fn lower_event( + &self, + event: &hir::Event, + annotations: &[hir::Annotation], + ) -> Result { + if !event.args.is_empty() { + return Err(self.unsupported( + "event arguments are not representable in canonical WIR; use structural event filters", + event.span, + )); + } + let team = self.lower_event_team(annotations)?; + let target = self.lower_event_target(annotations)?; + let has_filters = + !matches!(team, wir::EventTeam::All) || !matches!(target, wir::EventTarget::All); + match event.name.as_str() { + "global" => { + if has_filters { + return Err( + self.unsupported("global events cannot have player filters", event.span) + ); + } + Ok(Event::Global) + } + "eachPlayer" => { + if has_filters { + Ok(Event::EachPlayerWithFilters { team, target }) + } else { + Ok(Event::EachPlayer) + } + } + name => player_event_kind(name).map_or_else( + || { + Err(self.unsupported( + format!("event '{name}' is not supported by canonical WIR"), + event.span, + )) + }, + |kind| Ok(Event::Player { kind, team, target }), + ), + } + } + + fn lower_event_team( + &self, + annotations: &[hir::Annotation], + ) -> Result { + let team_annotations = annotations + .iter() + .filter(|annotation| annotation.name == "Team") + .collect::>(); + if team_annotations.len() > 1 { + return Err(self.unsupported( + "an event cannot have multiple @Team filters", + team_annotations[1].span.or(team_annotations[0].span), + )); + } + let Some(annotation) = team_annotations.first() else { + return Ok(wir::EventTeam::All); + }; + let argument = annotation + .args + .first() + .ok_or_else(|| self.unsupported("@Team requires one filter value", annotation.span))?; + if annotation.args.len() != 1 { + return Err( + self.unsupported("@Team requires exactly one filter value", annotation.span) + ); + } + let spelling = match argument.text.as_str() { + "1" => "Team 1", + "2" => "Team 2", + value => value, + }; + let (_, member) = self + .compiler + .catalog + .resolve_enum_member("EventTeam", &Locale::new("en-US"), spelling) + .ok_or_else(|| { + self.unsupported( + format!("unknown EventTeam filter '{spelling}'"), + argument.span.or(annotation.span), + ) + })?; + match member.as_str() { + "ALL" => Ok(wir::EventTeam::All), + "TEAM_1" => Ok(wir::EventTeam::Team1), + "TEAM_2" => Ok(wir::EventTeam::Team2), + _ => Err(self.unsupported( + format!("catalog EventTeam member '{member}' is not supported by canonical WIR"), + argument.span.or(annotation.span), + )), + } + } + + fn lower_event_target( + &self, + annotations: &[hir::Annotation], + ) -> Result { + let mut filters = Vec::new(); + for name in ["Slot", "Hero"] { + let matches = annotations + .iter() + .filter(|annotation| annotation.name == name) + .collect::>(); + if matches.len() > 1 { + return Err(self.unsupported( + format!("an event cannot have multiple @{name} filters"), + matches[1].span.or(matches[0].span), + )); + } + filters.extend(matches); + } + if filters.len() > 1 { + return Err(self.unsupported( + "an event cannot combine @Slot and @Hero filters", + filters[1].span.or(filters[0].span), + )); + } + let Some(annotation) = filters.first() else { + return Ok(wir::EventTarget::All); + }; + let argument = annotation.args.first().ok_or_else(|| { + self.unsupported( + format!("@{} requires one filter value", annotation.name), + annotation.span, + ) + })?; + if annotation.args.len() != 1 { + return Err(self.unsupported( + format!("@{} requires exactly one filter value", annotation.name), + annotation.span, + )); + } + let spelling = if annotation.name == "Slot" { + match argument.text.as_str() { + value if value.parse::().is_ok() => { + format!("Slot {}", value.parse::().unwrap_or_default()) + } + value => value.to_string(), + } + } else { + argument.text.clone() + }; + let domain = if annotation.name == "Slot" { + "EventPlayer" + } else { + "Hero" + }; + let (_, member) = self + .compiler + .catalog + .resolve_enum_member(domain, &Locale::new("en-US"), &spelling) + .ok_or_else(|| { + self.unsupported( + format!("unknown {domain} filter '{spelling}'"), + argument.span.or(annotation.span), + ) + })?; + if domain == "EventPlayer" { + if member == "ALL" { + Ok(wir::EventTarget::All) + } else if let Some(slot) = member.strip_prefix("SLOT_") { + let slot = slot.parse::().map_err(|_| { + self.unsupported( + format!("catalog EventPlayer member '{member}' is not a slot"), + argument.span.or(annotation.span), + ) + })?; + Ok(wir::EventTarget::Slot(slot)) + } else { + Err(self.unsupported( + format!( + "catalog EventPlayer member '{member}' is not supported by canonical WIR" + ), + argument.span.or(annotation.span), + )) + } + } else { + Ok(wir::EventTarget::Hero(member)) + } + } + fn lower_action(&mut self, stmt: &Stmt) -> Result { match stmt { Stmt::Assign { @@ -413,7 +808,7 @@ impl<'a> Lowering<'a> { } = target.as_ref() else { return Err(self.unsupported( - "only global-variable assignment is in the #35 vertical slice", + "only global-variable assignment is currently representable in canonical WIR", *span, )); }; @@ -431,14 +826,25 @@ impl<'a> Lowering<'a> { Stmt::Expr { expr, span } => { let Expr::Call { name, args, .. } = expr.as_ref() else { return Err(self.unsupported( - "only builtin action calls are in the #35 vertical slice", + "only builtin action calls are currently representable in canonical WIR", *span, )); }; self.lower_action_call(name, args, *span) } + Stmt::CallSubroutine { name, span } => { + let subroutine = *self.subroutines.get(name).ok_or_else(|| { + self.unsupported(format!("unknown subroutine '{name}'"), *span) + })?; + let span = self.wir_span(*span)?; + Ok(self.wir.actions.push(Action::CallSubroutine { + subroutine, + span, + callee_span: span, + })) + } _ => Err(self.unsupported( - "the statement is outside the #35 vertical slice", + "the statement is not currently representable in canonical WIR", stmt.span().copied(), )), } @@ -461,7 +867,7 @@ impl<'a> Lowering<'a> { let catalog_id = function.catalog_id.as_ref().ok_or_else(|| { self.unsupported( format!( - "action '{}' requires a special lowering not in #35", + "action '{}' requires a special lowering not in #40", function.id ), span, @@ -536,7 +942,7 @@ impl<'a> Lowering<'a> { let catalog_id = function.catalog_id.as_ref().ok_or_else(|| { self.unsupported( format!( - "value '{}' requires a special lowering not in #35", + "value '{}' requires a special lowering not in #40", function.id ), span, @@ -563,7 +969,7 @@ impl<'a> Lowering<'a> { _ => { return Err(self.unsupported( format!( - "expression '{}' is outside the #35 vertical slice", + "expression '{}' is not currently representable in canonical WIR", expr.kind_name() ), span, @@ -614,6 +1020,71 @@ impl<'a> Lowering<'a> { } } +fn allocate_indices( + entries: &[(Option, Option)], + kind: &str, +) -> Result, IntegrationError> { + let mut reserved = HashSet::new(); + for (index, span) in entries { + let Some(index) = index else { + continue; + }; + if !reserved.insert(*index) { + return Err(IntegrationError::new( + "index-collision", + format!("duplicate explicit {kind} index {index}"), + *span, + )); + } + } + + let mut next = 0; + let mut allocated = Vec::with_capacity(entries.len()); + for (index, span) in entries { + let assigned = if let Some(index) = index { + *index + } else { + while reserved.contains(&next) { + next = next.checked_add(1).ok_or_else(|| { + IntegrationError::new( + "index-exhausted", + format!("no available {kind} index remains"), + *span, + ) + })?; + } + reserved.insert(next); + let assigned = next; + next = next.checked_add(1).ok_or_else(|| { + IntegrationError::new( + "index-exhausted", + format!("no available {kind} index remains"), + *span, + ) + })?; + assigned + }; + next = next.max(assigned.saturating_add(1)); + allocated.push(assigned); + } + Ok(allocated) +} + +fn player_event_kind(name: &str) -> Option { + Some(match name { + "playerDealtDamage" => PlayerEventKind::DealtDamage, + "playerDealtFinalBlow" => PlayerEventKind::DealtFinalBlow, + "playerDealtHealing" => PlayerEventKind::DealtHealing, + "playerDied" => PlayerEventKind::Died, + "playerEarnedElimination" => PlayerEventKind::EarnedElimination, + "playerJoined" => PlayerEventKind::Joined, + "playerLeft" => PlayerEventKind::Left, + "playerReceivedHealing" => PlayerEventKind::ReceivedHealing, + "playerTookDamage" => PlayerEventKind::TookDamage, + _ => return None, + }) +} + fn workshop_error_span(error: &workshop_rs::WorkshopError) -> Option { match error { workshop_rs::WorkshopError::Unknown { span, .. } @@ -695,4 +1166,105 @@ mod tests { assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); assert_eq!(error.diagnostic.span.unwrap().start.line, 3); } + + #[test] + fn structural_subroutines_lower_to_canonical_wir() { + let compiler = Compiler::new().unwrap(); + let hir = opy_frontend::compile( + "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n", + "structure.opy", + Path::new("."), + ) + .unwrap(); + let artifact = compiler.compile_hir(&hir).unwrap(); + let subroutine = artifact + .wir + .subroutines + .get(workshop_rs::wir::SubroutineId::from_index(0)) + .unwrap(); + assert_eq!(subroutine.name, "Friendly"); + assert_eq!(subroutine.index, 0); + assert_eq!(subroutine.name_span.unwrap().start.line, 2); + assert_eq!(artifact.wir.rules.len(), 2); + assert!(matches!( + artifact + .wir + .rules + .get(workshop_rs::wir::RuleId::from_index(0)) + .unwrap() + .event, + workshop_rs::wir::Event::Subroutine(_) + )); + assert!(matches!( + artifact + .wir + .actions + .get(workshop_rs::wir::ActionId::from_index(1)) + .unwrap(), + workshop_rs::wir::Action::CallSubroutine { .. } + )); + assert!(artifact.emitted.contains("Subroutine Friendly")); + } + + #[test] + fn player_event_filters_resolve_through_canonical_catalog() { + let compiler = Compiler::new().unwrap(); + let hir = opy_frontend::compile( + "rule \"joined\":\n @Event playerJoined\n @Team 1\n @Slot 2\n disableInspector()\n", + "filters.opy", + Path::new("."), + ) + .unwrap(); + let artifact = compiler.compile_hir(&hir).unwrap(); + assert!(matches!( + &artifact + .wir + .rules + .get(workshop_rs::wir::RuleId::from_index(0)) + .unwrap() + .event, + workshop_rs::wir::Event::Player { + kind: workshop_rs::wir::PlayerEventKind::Joined, + team: workshop_rs::wir::EventTeam::Team1, + target: workshop_rs::wir::EventTarget::Slot(2), + } + )); + assert!(artifact.emitted.contains("Player Joined Match;")); + } + + #[test] + fn explicit_indices_are_reserved_before_deterministic_allocation() { + let compiler = Compiler::new().unwrap(); + let hir = opy_frontend::compile( + "globalvar first\nglobalvar reserved 0\nglobalvar next\nrule \"indices\":\n @Event global\n disableInspector()\n", + "indices.opy", + Path::new("."), + ) + .unwrap(); + let artifact = compiler.compile_hir(&hir).unwrap(); + let indices = artifact + .wir + .global_variables + .iter() + .map(|variable| variable.index) + .collect::>(); + assert_eq!(indices, vec![1, 0, 2]); + } + + #[test] + fn unsupported_rule_metadata_is_explicit_and_source_attributed() { + let compiler = Compiler::new().unwrap(); + let hir = opy_frontend::compile( + "rule \"metadata\":\n @Event global\n @NewPage \"section\"\n disableInspector()\n", + "metadata.opy", + Path::new("."), + ) + .unwrap(); + let error = match compiler.compile_hir(&hir) { + Ok(_) => panic!("unsupported metadata unexpectedly succeeded"), + Err(error) => error, + }; + assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); + assert_eq!(error.diagnostic.span.unwrap().start.line, 3); + } } diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index f2d5094..0717981 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -31,8 +31,9 @@ suite are implemented and CI-covered. The rows they evidence are flipped to `frontend-supported`/`semantic-supported` in `compatibility/support-matrix.json`, the mechanically checked state source; features whose completion requires the full canonical Workshop surface remain -`lowering-dependent`; the bounded #35 adapter is separately recorded as -`end-to-end-supported` and does not reclassify broader Workshop-owned rows. +`lowering-dependent`; the bounded #35 adapter and #40 structural HIR → +canonical WIR lowering are separately recorded as `end-to-end-supported` and +do not reclassify broader Workshop-owned rows. Here, `end-to-end-supported` is scoped to the explicitly evidenced feature or vertical slice; it never means full-language OPY-to-Workshop parity. The wider #8 lowering stage remains outside this frontend gate. Per-fixture differential status (resolve / @@ -55,6 +56,7 @@ Workshop-independent up to the documented integration boundary toward | `compatibility/fixtures/real-world/{ow1-emulator,6v6-adjustments}/` | Independent third-party projects (BSD-2-Clause), full include closures | | `compatibility/fixtures/**/oracle.json` | Pinned OverPy 9.7.10 reference snapshots (normalized Workshop output, diagnostics, exit codes) | | `compatibility/fixtures/synthetic/issue-35-integration/` | #35 OPY-to-Workshop vertical-slice evidence; oracle provenance remains separate from implementation-specific WIR/emission assertions | +| `crates/opy-compiler/src/lib.rs` structural tests | #40 declarations, subroutines, rules, event filters, deterministic indices, and source-attributed negative lowering evidence | | `compatibility/support-matrix.json` | Machine-readable state tracking of every declared feature (the mechanically checkable artifact) | | `crates/opy-frontend/src/manifest/` | The opy-rs-owned semantic compatibility manifest and its oracle probes (ported with the frontend, issue #3/#4) | | `crates/opy-frontend/tests/differential.rs` + `compatibility/diff.py` | Native-vs-reference differential parity (issue #7): the rust suite runs every corpus fixture through the native pipeline in `cargo test` (no Node), compares status/rule-name evidence against the recorded `oracle.json` snapshots, and writes `target/opy-differential-report.json` | @@ -174,7 +176,10 @@ The pinned reference ABI (`src/compiler/tokenizer.ts`, `src/quickjs.ts`, `@NewPage`, and `@SuppressWarnings` are parsed, validated, and retained in the OPY HIR. Hero/team/slot domain checks and Workshop UI effects remain lowering-dependent; malformed or misplaced annotations fail with structured - source-located diagnostics. + source-located diagnostics. Issue #40 lowers the WIR-representable subset: + disabled state, canonical event identities, and catalog-resolved team/slot/ + hero filters. Delimiter, new-page, suppression, and other metadata without a + canonical WIR carrier remain explicit integration diagnostics. - Statements: expression statements, `=` and augmented assignment, `if`/`elif`/`else`, `for x in range(...)`, `while`, `pass`. - `for`-loop binder resolution: the loop variable must resolve to a global From c8ecb4b84147b1ae91b3c62dde729c5b623df92e Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 15:21:48 +0800 Subject: [PATCH 2/3] fix(opy-compiler): preserve subroutine identity and oracle evidence Keep Workshop subroutine names tied to source symbols, consume SuppressWarnings as frontend metadata, and add a pinned #40 structural fixture covering identity, allocation, and event filters. Refs #40 --- Cargo.lock | 1 + compatibility/README.md | 2 +- compatibility/differential-expectations.json | 1 + compatibility/fixtures/README.md | 4 +- .../issue-40-structural/fixture.json | 14 +++ .../synthetic/issue-40-structural/oracle.json | 27 +++++ .../synthetic/issue-40-structural/source.opy | 18 +++ compatibility/support-matrix.json | 5 +- compatibility/tests/test_runner.py | 2 +- crates/opy-compiler/Cargo.toml | 3 + crates/opy-compiler/src/lib.rs | 103 +++++++++++++----- crates/opy-frontend/tests/differential.rs | 8 +- docs/opy/architecture.md | 2 +- docs/opy/compatibility-baseline.md | 2 +- docs/opy/support-matrix.md | 1 + 15 files changed, 155 insertions(+), 38 deletions(-) create mode 100644 compatibility/fixtures/synthetic/issue-40-structural/fixture.json create mode 100644 compatibility/fixtures/synthetic/issue-40-structural/oracle.json create mode 100644 compatibility/fixtures/synthetic/issue-40-structural/source.opy diff --git a/Cargo.lock b/Cargo.lock index 494c7bd..15e6b71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,7 @@ name = "opy-compiler" version = "0.1.0" dependencies = [ "opy-frontend", + "serde_json", "workshop-rs", ] diff --git a/compatibility/README.md b/compatibility/README.md index 113d601..6dea2fd 100644 --- a/compatibility/README.md +++ b/compatibility/README.md @@ -45,7 +45,7 @@ compatibility/fixtures/// Imported fixtures should also record an immutable `sourceCommit`, a direct `sourceUrl`, a `licenseUrl`, and whether the source was modified. The corpus -contains 41 fixtures: 27 WrightKit-authored synthetic cases, one census boundary +contains 42 fixtures: 28 WrightKit-authored synthetic cases, one census boundary fixture, and 13 real-world projects (11 derived from the pinned OverPy `examples/` tree, GPL-3.0-only, provenance-recorded evidence, plus the independent BSD-2-Clause diff --git a/compatibility/differential-expectations.json b/compatibility/differential-expectations.json index 97f760d..72cf47e 100644 --- a/compatibility/differential-expectations.json +++ b/compatibility/differential-expectations.json @@ -47,6 +47,7 @@ {"fixture": "real-world/ow1-emulator", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/ow1-emulator/oracle.json", "provenance:real-world/ow1-emulator/fixture.json"], "note": "The full project remains preserved as a failure corpus case with recorded provenance."}, {"fixture": "real-world/6v6-adjustments", "nativeStatus": "failure", "classification": "match", "ruleNames": false, "evidence": ["oracle:real-world/6v6-adjustments/oracle.json", "provenance:real-world/6v6-adjustments/fixture.json"], "note": "The full project remains preserved as a failure corpus case with recorded provenance."}, {"fixture": "synthetic/issue-35-integration", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:synthetic/issue-35-integration/oracle.json", "implementation-invariant:opy-compiler-vertical-slice"], "note": "The OPY frontend resolves the source fixture; the dedicated opy-compiler test independently lowers it through canonical WIR validation and deterministic workshop-rs emission."}, + {"fixture": "synthetic/issue-40-structural", "nativeStatus": "success", "classification": "match", "ruleNames": false, "evidence": ["oracle:synthetic/issue-40-structural/oracle.json", "implementation-invariant:opy-compiler-structural-lowering"], "note": "The pinned oracle records subroutine source identity, deterministic explicit/implicit variable allocation, and player event filters; the dedicated opy-compiler test independently asserts those structures in canonical WIR."}, {"fixture": "census/workshop-feature-census", "nativeStatus": "success", "classification": "match", "ruleNames": true, "evidence": ["oracle:census/workshop-feature-census/oracle.json", "contract:workshop-rs#10-feature-census"], "note": "OPy source records opaque Workshop feature identities for the future workshop-rs lowering boundary."} ] } diff --git a/compatibility/fixtures/README.md b/compatibility/fixtures/README.md index b7f52fc..34655ce 100644 --- a/compatibility/fixtures/README.md +++ b/compatibility/fixtures/README.md @@ -3,7 +3,7 @@ This directory is the opy-rs compatibility corpus: OPY sources with their pinned-oracle snapshots (`oracle.json`), ported from the WrightKit project's evidence base (wright `compatibility/fixtures/`) and re-verified against the -pinned OverPy 9.7.10 oracle on 2026-08-17 (all 41 snapshots match). +pinned OverPy 9.7.10 oracle on 2026-08-17 (all 42 snapshots match). Corpus policy: every fixture records provenance in its `fixture.json` (`kind`, `origin`, `license`, `redistributable`, and — for imported @@ -121,7 +121,7 @@ reference diagnostics, exactly like the pinned oracle behaves. ## Not ported / dropped -* **No fixture was dropped for provenance reasons**: all 41 fixtures in the +* **No fixture was dropped for provenance reasons**: all 42 fixtures in the WrightKit corpus carried complete, reviewed provenance and are ported. * Upstream `examples/` not ported (candidates for later expansion once a demonstrated need exists): `lucioball_all_heroes.opy`, `skirmish_elim.opy`, diff --git a/compatibility/fixtures/synthetic/issue-40-structural/fixture.json b/compatibility/fixtures/synthetic/issue-40-structural/fixture.json new file mode 100644 index 0000000..7331321 --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-40-structural/fixture.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "synthetic/issue-40-structural", + "category": "synthetic", + "features": ["compilation/opy-structural-lowering"], + "source": "source.opy", + "expectedStatus": "success", + "provenance": { + "kind": "original", + "origin": "opy-rs Issue #40 minimized structural lowering probe", + "license": "AGPL-3.0-or-later", + "redistributable": true + } +} diff --git a/compatibility/fixtures/synthetic/issue-40-structural/oracle.json b/compatibility/fixtures/synthetic/issue-40-structural/oracle.json new file mode 100644 index 0000000..6b85aec --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-40-structural/oracle.json @@ -0,0 +1,27 @@ +{ + "compile": { + "diagnostics": [], + "exitCode": 0, + "status": "success", + "stdout": "", + "workshop": "variables {\n global:\n 0: reserved\n 1: first\n 2: explicit\n 3: next\n}\n\nsubroutines {\n 0: helper\n}\n\nrule (\"[Source] renamed helper\") {\n event {\n Subroutine;\n helper;\n }\n actions {\n Set Global Variable(first, 1);\n }\n}\n\nrule (\"[Source] joined\") {\n event {\n Player Joined Match;\n Team 1;\n Slot 2;\n }\n actions {\n Call Subroutine(helper);\n }\n}\n", + "workshopExact": "variables {\n global:\n 0: reserved\n 1: first\n 2: explicit\n 3: next\n}\n\nsubroutines {\n 0: helper\n}\n\nrule (\"[Source] renamed helper\") {\n event {\n Subroutine;\n helper;\n }\n actions {\n Set Global Variable(first, 1);\n }\n}\n\nrule (\"[Source] joined\") {\n event {\n Player Joined Match;\n Team 1;\n Slot 2;\n }\n actions {\n Call Subroutine(helper);\n }\n}\n\n", + "workshopSha256": "9cadaf38b68d2b864c2f7bb822c649ab5d0fff5eaf17712d02eb3ccbef75df34" + }, + "fixture": "synthetic/issue-40-structural", + "input": { + "sha256": "c3a1daee735d48e670cf98fed74872b02480d9a7064a3340eadae3278d4eb7b8", + "source": "source.opy" + }, + "oracle": { + "gitHead": "1e2688954302a402d076944b46db07efb14d7b61", + "integrity": "sha512-oX17nauJcPTaKIrRFY/rD0Rl8atqFUVv9Hg2TKH+A68/fC8+ZO344Mkd1A/Y0oOVp1hr5tktMBjzMEDDnMEYUw==", + "language": "en-US", + "license": "GPL-3.0-only", + "name": "overpy", + "registryTarball": "https://registry.npmjs.org/overpy/-/overpy-9.7.10.tgz", + "repository": "https://github.com/Zezombye/overpy", + "version": "9.7.10" + }, + "schemaVersion": 1 +} diff --git a/compatibility/fixtures/synthetic/issue-40-structural/source.opy b/compatibility/fixtures/synthetic/issue-40-structural/source.opy new file mode 100644 index 0000000..b01aadf --- /dev/null +++ b/compatibility/fixtures/synthetic/issue-40-structural/source.opy @@ -0,0 +1,18 @@ +#!rulePrefixTemplate +globalvar reserved 0 +globalvar first +globalvar explicit 2 +globalvar next +subroutine helper + +def helper(): + @Name "renamed helper" + @SuppressWarnings unusedVariable + first = 1 + +rule "joined": + @Event playerJoined + @Team 1 + @Slot 2 + @SuppressWarnings unusedVariable + helper() diff --git a/compatibility/support-matrix.json b/compatibility/support-matrix.json index 7481900..5372e35 100644 --- a/compatibility/support-matrix.json +++ b/compatibility/support-matrix.json @@ -9,7 +9,7 @@ }, "snapshot": { "date": "2026-08-18", - "note": "Readiness baseline for #7 through the #28/#29/#30/#33 Draft PR series plus the bounded #35 and #40 integration slices. Frontend-supported rows include the pinned OPY syntax, directives, preprocessing, macro statements, rule directives/model, JavaScript macros, and runtime hooks. Semantic-supported rows include declaration resolution, for-loop binders, modules, keyword arguments, the declared alias surface, and the OPY-owned manifest overlay for builtin/member/enum semantics. Canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. The 41-fixture differential corpus currently reports 34 matches, 7 explicit known gaps, 0 unexpected divergences, and 0 inconclusive results. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", + "note": "Readiness baseline for #7 through the #28/#29/#30/#33 Draft PR series plus the bounded #35 and #40 integration slices. Frontend-supported rows include the pinned OPY syntax, directives, preprocessing, macro statements, rule directives/model, JavaScript macros, and runtime hooks. Semantic-supported rows include declaration resolution, for-loop binders, modules, keyword arguments, the declared alias surface, and the OPY-owned manifest overlay for builtin/member/enum semantics. Canonical Workshop builtin/member/enum breadth and emission remain separate lowering-dependent rows; no Workshop catalog data is copied into opy-rs. The 42-fixture differential corpus currently reports 35 matches, 7 explicit known gaps, 0 unexpected divergences, and 0 inconclusive results. #!postCompileHook is parsed/validated/recorded by the frontend; execution against final Workshop text is lowering-dependent (#8).", "asOfCommit": "4d88daf4e3445af37ce06774d789a94c5bcbe355" }, "states": { @@ -599,7 +599,7 @@ "docs:docs/hir/opy-hir-v1.md", "test:opy-frontend-differential" ], - "notes": "Issues #3-#7, #25, and the #28/#29/#30/#33 readiness tracks. Fully Workshop-independent; the 41-fixture corpus is the acceptance corpus. Differential harness runs every fixture through the native pipeline in cargo test with structural self-checks (HIR validation, wire round-trip, deterministic dump), status/rule-name parity against recorded oracle.json snapshots, explicit native evidence expectations, and a machine-readable report (target/opy-differential-report.json). The current report has 34 matches, 7 reference-success/native-failure cases classified as known gaps, and no unexpected divergence or inconclusive result." + "notes": "Issues #3-#7, #25, and the #28/#29/#30/#33 readiness tracks. Fully Workshop-independent; the 42-fixture corpus is the acceptance corpus. Differential harness runs every fixture through the native pipeline in cargo test with structural self-checks (HIR validation, wire round-trip, deterministic dump), status/rule-name parity against recorded oracle.json snapshots, explicit native evidence expectations, and a machine-readable report (target/opy-differential-report.json). The current report has 35 matches, 7 reference-success/native-failure cases classified as known gaps, and no unexpected divergence or inconclusive result." }, { "id": "compilation/opy-integration-vertical-slice", @@ -620,6 +620,7 @@ "state": "end-to-end-supported", "evidence": [ "fixtures:synthetic/issue-35-integration", + "fixtures:synthetic/issue-40-structural", "test:opy-compiler-structural-lowering", "contract:workshop-rs-v0.1.1" ], diff --git a/compatibility/tests/test_runner.py b/compatibility/tests/test_runner.py index a14e674..649d979 100644 --- a/compatibility/tests/test_runner.py +++ b/compatibility/tests/test_runner.py @@ -36,7 +36,7 @@ def test_repository_fixture_metadata_and_snapshots_are_valid(self): fixtures = run_oracle.discover_fixtures( COMPATIBILITY_DIR / "fixtures" ) - self.assertEqual(len(fixtures), 41) + self.assertEqual(len(fixtures), 42) for fixture_path, fixture in fixtures: snapshot = fixture_path.parent / "oracle.json" self.assertTrue(snapshot.is_file(), fixture["id"]) diff --git a/crates/opy-compiler/Cargo.toml b/crates/opy-compiler/Cargo.toml index 4f322c4..2597030 100644 --- a/crates/opy-compiler/Cargo.toml +++ b/crates/opy-compiler/Cargo.toml @@ -12,3 +12,6 @@ workspace = true [dependencies] opy-frontend = { path = "../opy-frontend" } workshop-rs = "=0.1.1" + +[dev-dependencies] +serde_json = "1" diff --git a/crates/opy-compiler/src/lib.rs b/crates/opy-compiler/src/lib.rs index 576154b..de57a2a 100644 --- a/crates/opy-compiler/src/lib.rs +++ b/crates/opy-compiler/src/lib.rs @@ -518,27 +518,12 @@ impl<'a> Lowering<'a> { name_span.or(span), )); } - if let Some(declaration) = self.wir.subroutines.get_mut(subroutine) { - declaration.name = name.to_string(); - } - if let Some(existing) = self.subroutines.get(name) { - if *existing != subroutine { - return Err(self.unsupported( - format!( - "subroutine presentation name '{name}' collides with another subroutine" - ), - name_span.or(span), - )); - } - } else { - self.subroutines.insert(name.to_string(), subroutine); - } let actions = body .iter() .map(|stmt| self.lower_action(stmt)) .collect::, _>>()?; self.wir.rules.push(wir::Rule { - name: format!("Subroutine {name}"), + name: self.subroutine_rule_name(name), span: self.wir_span(span)?, name_span: self.wir_span(name_span)?, disabled: false, @@ -576,7 +561,8 @@ impl<'a> Lowering<'a> { } for annotation in &rule.annotations { match annotation.name.as_str() { - "Event" | "Condition" | "Team" | "Slot" | "Hero" | "Disabled" => {} + "Event" | "Condition" | "Team" | "Slot" | "Hero" | "Disabled" + | "SuppressWarnings" => {} _ => { return Err(self.unsupported( format!( @@ -597,7 +583,7 @@ impl<'a> Lowering<'a> { ) -> Result<(), IntegrationError> { for annotation in annotations { match annotation.name.as_str() { - "Name" => {} + "Name" | "SuppressWarnings" => {} _ => { return Err(self.unsupported( format!( @@ -612,6 +598,14 @@ impl<'a> Lowering<'a> { Ok(()) } + fn subroutine_rule_name(&self, generated_name: &str) -> String { + if self.hir.preprocessing.rule_prefix_template.is_some() { + generated_name.to_string() + } else { + format!("Subroutine {generated_name}") + } + } + fn lower_event( &self, event: &hir::Event, @@ -1171,7 +1165,7 @@ mod tests { fn structural_subroutines_lower_to_canonical_wir() { let compiler = Compiler::new().unwrap(); let hir = opy_frontend::compile( - "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n", + "globalvar score\nsubroutine showStatus\ndef showStatus():\n @Name \"Friendly\"\n @SuppressWarnings unusedVariable\n disableInspector()\nrule \"caller\":\n @Event global\n showStatus()\n", "structure.opy", Path::new("."), ) @@ -1182,19 +1176,22 @@ mod tests { .subroutines .get(workshop_rs::wir::SubroutineId::from_index(0)) .unwrap(); - assert_eq!(subroutine.name, "Friendly"); + assert_eq!(subroutine.name, "showStatus"); assert_eq!(subroutine.index, 0); assert_eq!(subroutine.name_span.unwrap().start.line, 2); assert_eq!(artifact.wir.rules.len(), 2); - assert!(matches!( - artifact - .wir - .rules - .get(workshop_rs::wir::RuleId::from_index(0)) - .unwrap() - .event, - workshop_rs::wir::Event::Subroutine(_) - )); + let subroutine_rule = artifact + .wir + .rules + .get(workshop_rs::wir::RuleId::from_index(0)) + .unwrap(); + let workshop_rs::wir::Event::Subroutine(subroutine_id) = subroutine_rule.event else { + panic!("expected a subroutine event"); + }; + assert_eq!( + artifact.wir.subroutines.get(subroutine_id).unwrap().name, + "showStatus" + ); assert!(matches!( artifact .wir @@ -1267,4 +1264,52 @@ mod tests { assert_eq!(error.diagnostic.code, "unsupported-integration-surface"); assert_eq!(error.diagnostic.span.unwrap().start.line, 3); } + + #[test] + fn issue_40_oracle_fixture_and_wir_lowering_agree() { + let compiler = Compiler::new().unwrap(); + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../compatibility/fixtures/synthetic/issue-40-structural"); + let source = std::fs::read_to_string(fixture.join("source.opy")).unwrap(); + let hir = opy_frontend::compile(&source, "source.opy", &fixture).unwrap(); + let artifact = compiler.compile_hir(&hir).unwrap(); + let oracle: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(fixture.join("oracle.json")).unwrap()) + .unwrap(); + let oracle_workshop = oracle["compile"]["workshop"].as_str().unwrap(); + + assert!(oracle_workshop.contains("0: reserved")); + assert!(oracle_workshop.contains("1: first")); + assert!(oracle_workshop.contains("2: explicit")); + assert!(oracle_workshop.contains("3: next")); + assert!(oracle_workshop.contains("0: helper")); + assert!(oracle_workshop.contains("Subroutine;\n helper;")); + assert!(oracle_workshop.contains("Player Joined Match;\n Team 1;\n Slot 2;")); + + let indices = artifact + .wir + .global_variables + .iter() + .map(|variable| variable.index) + .collect::>(); + assert_eq!(indices, vec![0, 1, 2, 3]); + assert_eq!( + artifact.wir.subroutines.iter().next().unwrap().name, + "helper" + ); + assert!(artifact.emitted.contains("[Source] renamed helper")); + assert!(matches!( + artifact + .wir + .rules + .get(workshop_rs::wir::RuleId::from_index(1)) + .unwrap() + .event, + workshop_rs::wir::Event::Player { + kind: workshop_rs::wir::PlayerEventKind::Joined, + team: workshop_rs::wir::EventTeam::Team1, + target: workshop_rs::wir::EventTarget::Slot(2), + } + )); + } } diff --git a/crates/opy-frontend/tests/differential.rs b/crates/opy-frontend/tests/differential.rs index 4e4abb9..eb7d927 100644 --- a/crates/opy-frontend/tests/differential.rs +++ b/crates/opy-frontend/tests/differential.rs @@ -67,7 +67,7 @@ //! //! # Current corpus state //! -//! All declared fixtures run (0 skips, 0 divergences): **15 resolve** and +//! All declared fixtures run (0 skips, 0 divergences): **16 resolve** and //! **12 produce expected diagnostics** with pinned codes; 7 fixtures are //! documented reference gaps (the oracle accepts a surface the native //! frontend deliberately rejects). Settings key-existence/leaf-kind @@ -193,6 +193,12 @@ fn declared_corpus() -> BTreeMap<&'static str, Case> { true, "Issue #35 OPY-to-Workshop integration fixture; the frontend resolves the source and the opy-compiler crate independently validates the canonical WIR slice.", ); + resolve( + &mut cases, + "synthetic/issue-40-structural", + false, + "Issue #40 oracle-backed structural probe; the frontend resolves the source while opy-compiler independently checks canonical WIR identity, allocation, and event filters.", + ); diagnostic( &mut cases, "synthetic/issue-33-lambda-negative", diff --git a/docs/opy/architecture.md b/docs/opy/architecture.md index fe16ade..fc330b4 100644 --- a/docs/opy/architecture.md +++ b/docs/opy/architecture.md @@ -176,7 +176,7 @@ complete; #28/#29/#30/#33 executed): * the OPY semantic compatibility manifest with oracle-validated probes; * JavaScript macro execution and record-only post-compile hooks; * the `check`/`inspect`/`support` tooling API and `opy-cli`; -* the 41-fixture compatibility corpus with pinned oracle snapshots and the +* the 42-fixture compatibility corpus with pinned oracle snapshots and the native differential suite (`cargo test -p opy-frontend --test differential`). diff --git a/docs/opy/compatibility-baseline.md b/docs/opy/compatibility-baseline.md index e3feeb0..ff488f9 100644 --- a/docs/opy/compatibility-baseline.md +++ b/docs/opy/compatibility-baseline.md @@ -17,7 +17,7 @@ The reference identity is the pinned OverPy 9.7.10 content (`889d9749d1def17f146548cbddb94ea1ab015847`); see [`docs/compatibility/upstream-references.md`](../compatibility/upstream-references.md) for provenance. Evidence claims in this document were verified against the -pinned oracle (the declared corpus now contains 41 provenance-linked +pinned oracle (the declared corpus now contains 42 provenance-linked snapshots). The opy-rs frontend foundation and #7 readiness work are implemented on `main` (issues #3–#7, #28–#30, and #33); the category table is the **tier assignment contract** for the remaining surface. The state column of diff --git a/docs/opy/support-matrix.md b/docs/opy/support-matrix.md index 0717981..545828a 100644 --- a/docs/opy/support-matrix.md +++ b/docs/opy/support-matrix.md @@ -56,6 +56,7 @@ Workshop-independent up to the documented integration boundary toward | `compatibility/fixtures/real-world/{ow1-emulator,6v6-adjustments}/` | Independent third-party projects (BSD-2-Clause), full include closures | | `compatibility/fixtures/**/oracle.json` | Pinned OverPy 9.7.10 reference snapshots (normalized Workshop output, diagnostics, exit codes) | | `compatibility/fixtures/synthetic/issue-35-integration/` | #35 OPY-to-Workshop vertical-slice evidence; oracle provenance remains separate from implementation-specific WIR/emission assertions | +| `compatibility/fixtures/synthetic/issue-40-structural/` | #40 pinned OverPy oracle evidence for subroutine identity, deterministic variable allocation, and player event filters | | `crates/opy-compiler/src/lib.rs` structural tests | #40 declarations, subroutines, rules, event filters, deterministic indices, and source-attributed negative lowering evidence | | `compatibility/support-matrix.json` | Machine-readable state tracking of every declared feature (the mechanically checkable artifact) | | `crates/opy-frontend/src/manifest/` | The opy-rs-owned semantic compatibility manifest and its oracle probes (ported with the frontend, issue #3/#4) | From 1232be5a87f9c3f1652dce610f2f9b1d355f6baa Mon Sep 17 00:00:00 2001 From: Teakowa Date: Tue, 18 Aug 2026 15:31:05 +0800 Subject: [PATCH 3/3] test(compatibility): remove fixture count assertion Keep corpus validation focused on discovered fixture metadata and snapshots instead of a hard-coded count. Refs #40 --- compatibility/tests/test_runner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/compatibility/tests/test_runner.py b/compatibility/tests/test_runner.py index 649d979..bc71828 100644 --- a/compatibility/tests/test_runner.py +++ b/compatibility/tests/test_runner.py @@ -36,7 +36,6 @@ def test_repository_fixture_metadata_and_snapshots_are_valid(self): fixtures = run_oracle.discover_fixtures( COMPATIBILITY_DIR / "fixtures" ) - self.assertEqual(len(fixtures), 42) for fixture_path, fixture in fixtures: snapshot = fixture_path.parent / "oracle.json" self.assertTrue(snapshot.is_file(), fixture["id"])