Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions crates/ogar-from-ruff/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActionDef> {
// 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),
Expand Down Expand Up @@ -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()]);
}
}
74 changes: 62 additions & 12 deletions crates/ogar-from-ruff/src/mint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ActionDef>,
}

/// Mint the rail facet for every node in `graph`, resolving each node's
Expand All @@ -70,17 +77,28 @@ pub fn mint_graph<P: PortSpec>(graph: &ModelGraph) -> Mint {
#[must_use]
pub fn compile_graph_python<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClass> {
let mint = mint_graph::<P>(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
// the classid with empty tier chains (still a valid rail address).
let facet = mint
.facet(&node)
.unwrap_or_else(|| Facet::from_parts(classid_for_node::<P>(&node), [0; 6], [0; 6]));
CompiledClass { class, facet }
CompiledClass { class, facet, actions: lift_actions(model) }
})
.collect()
}
Expand Down Expand Up @@ -119,14 +137,23 @@ pub fn compile_graph_python<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClas
#[must_use]
pub fn compile_graph_sqlalchemy<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClass> {
let mint = mint_graph::<P>(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::<P>(&node), [0; 6], [0; 6]));
CompiledClass { class, facet }
CompiledClass { class, facet, actions: lift_actions(model) }
})
.collect()
}
Expand All @@ -145,14 +172,23 @@ pub fn compile_graph_sqlalchemy<P: PortSpec>(graph: &ModelGraph) -> Vec<Compiled
#[must_use]
pub fn compile_graph_ruby<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClass> {
let mint = mint_graph::<P>(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::<P>(&node), [0; 6], [0; 6]));
CompiledClass { class, facet }
CompiledClass { class, facet, actions: lift_actions(model) }
})
.collect()
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]
Expand Down
Loading