diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 1aefa88..d3d4ca5 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -532,9 +532,19 @@ pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'sta /// downstream at registration, not in the producer. #[must_use] pub fn lift_actions(model: &Model) -> Vec { + // Public actions AND non-public helpers (AT-CARRY-1b, review on #164): + // since ruff #45 the Ruby walker splits private/protected defs into + // `Model::helpers` — and Rails lifecycle hook targets are conventionally + // private (Redmine measurement: 67/84 hook targets live there). The W3.3 + // delete-blocking rows ARE those hook bodies, so the DO-arm must carry + // both. Consistent with this fn's contract: the producer emits effect + // annotations; routability / guard / RBAC enrichment happens downstream + // at registration (a registrar can re-join `Model::callbacks` by name to + // tell hook targets from routable actions). model .functions .iter() + .chain(model.helpers.iter()) .map(|f| { let mut a = ActionDef::new( format!("{}::action_def::{}", model.name, f.name), @@ -1812,4 +1822,32 @@ mod tests { // Mismatched / partial — pass-through. assert_eq!(strip_ruby_literal_markers("\"User"), "\"User"); } + + #[test] + fn lift_actions_carries_private_helper_hook_bodies() { + // AT-CARRY-1b (review on #164): Rails lifecycle hook targets are + // conventionally private -> `Model::helpers` (ruff #45). The DO-arm + // must carry their body facts, or the W3.3 delete-blocking rows + // never reach a consumer. + let mut m = Model::new("Issue"); + m.functions.push(ruff_spo_triplet::Function { + name: "public_action".to_string(), + ..Default::default() + }); + m.helpers.push(ruff_spo_triplet::Function { + name: "update_closed_on".to_string(), + writes: vec!["closed_on".to_string()], + reads: vec!["updated_on".to_string()], + ..Default::default() + }); + let actions = lift_actions(&m); + assert_eq!(actions.len(), 2, "public action + private hook body"); + let hook = actions + .iter() + .find(|a| a.predicate == "update_closed_on") + .expect("private hook body must arrive in the DO-arm"); + assert_eq!(hook.identity, "Issue::action_def::update_closed_on"); + assert_eq!(hook.writes, vec!["closed_on".to_string()]); + assert_eq!(hook.reads, vec!["updated_on".to_string()]); + } } diff --git a/crates/ogar-from-ruff/src/mint.rs b/crates/ogar-from-ruff/src/mint.rs index d914cf4..3d0e9be 100644 --- a/crates/ogar-from-ruff/src/mint.rs +++ b/crates/ogar-from-ruff/src/mint.rs @@ -29,14 +29,14 @@ //! [`OpenProjectPort`](ogar_vocab::ports::OpenProjectPort) — different render //! skins, the same conceptual identity where they converge. -use ogar_vocab::Class; +use ogar_vocab::{ActionDef, Class}; use ogar_vocab::app::render_classid_for; use ogar_vocab::ports::PortSpec; use ruff_spo_address::{Facet, Mint, mint_with_classid}; use ruff_spo_triplet::{ModelGraph, expand}; use crate::sqlalchemy::lift_model_graph_sqlalchemy; -use crate::{lift_model_graph, lift_model_graph_python}; +use crate::{lift_actions, lift_model_graph, lift_model_graph_python}; /// A class compiled to its rail-shaped, language-agnostic form: the lifted /// schema ([`Class`]) plus its 16-byte address ([`Facet`]). This is what a @@ -50,6 +50,13 @@ pub struct CompiledClass { /// The 16-byte rail facet: render classid + the `part_of` / `is_a` /// tier chains. pub facet: Facet, + /// The lifted DO-arm: one [`ActionDef`] per harvested method, carrying + /// the post-O-1 body facts (`reads` / `writes` / `calls`). AT-CARRY-1 of + /// the W3.3 delete gate (odoo-rs `docs/W3.3-DELETE-GATE-MATRIX.md`): + /// [`crate::lift_actions`] existed but was dropped here, so consumers + /// never saw the behaviour arm — the THINK arm (`class`) and the DO arm + /// (`actions`) now travel together, addressed by the same `facet`. + pub actions: Vec, } /// Mint the rail facet for every node in `graph`, resolving each node's @@ -70,9 +77,20 @@ pub fn mint_graph(graph: &ModelGraph) -> Mint { #[must_use] pub fn compile_graph_python(graph: &ModelGraph) -> Vec { let mint = mint_graph::

(graph); - lift_model_graph_python(graph) + let classes = lift_model_graph_python(graph); + // The lift maps `graph.models` 1:1 in declaration order (member-level + // filter_maps only), so zipping recovers each class's source `Model` — + // the carrier `lift_actions` needs (AT-CARRY-1). + assert_eq!( + classes.len(), + graph.models.len(), + "lift must map graph.models 1:1 in declaration order — a model-level \ + filter would silently mis-zip actions onto the wrong class" + ); + classes .into_iter() - .map(|class| { + .zip(&graph.models) + .map(|(class, model)| { let node = format!("{}:{}", graph.namespace, class.name); // A model always appears as a subject node (`rdf:type ObjectType`), // so it mints; the fallback covers a degenerate graph by stamping @@ -80,7 +98,7 @@ pub fn compile_graph_python(graph: &ModelGraph) -> Vec(&node), [0; 6], [0; 6])); - CompiledClass { class, facet } + CompiledClass { class, facet, actions: lift_actions(model) } }) .collect() } @@ -119,14 +137,23 @@ pub fn compile_graph_python(graph: &ModelGraph) -> Vec(graph: &ModelGraph) -> Vec { let mint = mint_graph::

(graph); - lift_model_graph_sqlalchemy(graph) + let classes = lift_model_graph_sqlalchemy(graph); + // 1:1 with `graph.models` in declaration order (see compile_graph_python). + assert_eq!( + classes.len(), + graph.models.len(), + "lift must map graph.models 1:1 in declaration order — a model-level \ + filter would silently mis-zip actions onto the wrong class" + ); + classes .into_iter() - .map(|class| { + .zip(&graph.models) + .map(|(class, model)| { let node = format!("{}:{}", graph.namespace, class.name); let facet = mint .facet(&node) .unwrap_or_else(|| Facet::from_parts(classid_for_node::

(&node), [0; 6], [0; 6])); - CompiledClass { class, facet } + CompiledClass { class, facet, actions: lift_actions(model) } }) .collect() } @@ -145,14 +172,23 @@ pub fn compile_graph_sqlalchemy(graph: &ModelGraph) -> Vec(graph: &ModelGraph) -> Vec { let mint = mint_graph::

(graph); - lift_model_graph(graph) + let classes = lift_model_graph(graph); + // 1:1 with `graph.models` in declaration order (see compile_graph_python). + assert_eq!( + classes.len(), + graph.models.len(), + "lift must map graph.models 1:1 in declaration order — a model-level \ + filter would silently mis-zip actions onto the wrong class" + ); + classes .into_iter() - .map(|class| { + .zip(&graph.models) + .map(|(class, model)| { let node = format!("{}:{}", graph.namespace, class.name); let facet = mint .facet(&node) .unwrap_or_else(|| Facet::from_parts(classid_for_node::

(&node), [0; 6], [0; 6])); - CompiledClass { class, facet } + CompiledClass { class, facet, actions: lift_actions(model) } }) .collect() } @@ -219,7 +255,8 @@ mod tests { }); m.functions.push(Function { name: "_compute_amount".to_string(), - reads: Vec::new(), + reads: vec!["line_ids.balance".to_string()], + writes: vec!["amount_total".to_string()], raises: Vec::new(), traverses: Vec::new(), ..Default::default() @@ -261,6 +298,19 @@ mod tests { OdooPort::APP_PREFIX, "Odoo render prefix", ); + + // AT-CARRY-1 (W3.3 delete gate, odoo-rs docs/W3.3-DELETE-GATE-MATRIX.md): + // the DO-arm travels WITH the compiled class — one ActionDef per + // harvested method, body facts intact. Before this field, lift_actions + // existed but compile_graph_* dropped it and no consumer ever saw the + // behaviour arm. + assert_eq!(cc.actions.len(), 1, "one ActionDef per harvested method"); + let act = &cc.actions[0]; + assert_eq!(act.predicate, "_compute_amount"); + assert_eq!(act.identity, "account_move::action_def::_compute_amount"); + assert_eq!(act.object_class, "account_move"); + assert_eq!(act.reads, vec!["line_ids.balance".to_string()]); + assert_eq!(act.writes, vec!["amount_total".to_string()]); } #[test]