Skip to content

Commit 4ed04ff

Browse files
committed
merge: origin/main (#162 compat + #164 CompiledClass.actions) into b1-integration
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 parents d31ff2a + 748ba11 commit 4ed04ff

2 files changed

Lines changed: 100 additions & 12 deletions

File tree

crates/ogar-from-ruff/src/lib.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,9 +540,19 @@ pub fn project_canonical_roles(class: &Class) -> std::collections::HashSet<&'sta
540540
/// downstream at registration, not in the producer.
541541
#[must_use]
542542
pub fn lift_actions(model: &Model) -> Vec<ActionDef> {
543+
// Public actions AND non-public helpers (AT-CARRY-1b, review on #164):
544+
// since ruff #45 the Ruby walker splits private/protected defs into
545+
// `Model::helpers` — and Rails lifecycle hook targets are conventionally
546+
// private (Redmine measurement: 67/84 hook targets live there). The W3.3
547+
// delete-blocking rows ARE those hook bodies, so the DO-arm must carry
548+
// both. Consistent with this fn's contract: the producer emits effect
549+
// annotations; routability / guard / RBAC enrichment happens downstream
550+
// at registration (a registrar can re-join `Model::callbacks` by name to
551+
// tell hook targets from routable actions).
543552
model
544553
.functions
545554
.iter()
555+
.chain(model.helpers.iter())
546556
.map(|f| {
547557
let mut a = ActionDef::new(
548558
format!("{}::action_def::{}", model.name, f.name),
@@ -1821,4 +1831,32 @@ mod tests {
18211831
// Mismatched / partial — pass-through.
18221832
assert_eq!(strip_ruby_literal_markers("\"User"), "\"User");
18231833
}
1834+
1835+
#[test]
1836+
fn lift_actions_carries_private_helper_hook_bodies() {
1837+
// AT-CARRY-1b (review on #164): Rails lifecycle hook targets are
1838+
// conventionally private -> `Model::helpers` (ruff #45). The DO-arm
1839+
// must carry their body facts, or the W3.3 delete-blocking rows
1840+
// never reach a consumer.
1841+
let mut m = Model::new("Issue");
1842+
m.functions.push(ruff_spo_triplet::Function {
1843+
name: "public_action".to_string(),
1844+
..Default::default()
1845+
});
1846+
m.helpers.push(ruff_spo_triplet::Function {
1847+
name: "update_closed_on".to_string(),
1848+
writes: vec!["closed_on".to_string()],
1849+
reads: vec!["updated_on".to_string()],
1850+
..Default::default()
1851+
});
1852+
let actions = lift_actions(&m);
1853+
assert_eq!(actions.len(), 2, "public action + private hook body");
1854+
let hook = actions
1855+
.iter()
1856+
.find(|a| a.predicate == "update_closed_on")
1857+
.expect("private hook body must arrive in the DO-arm");
1858+
assert_eq!(hook.identity, "Issue::action_def::update_closed_on");
1859+
assert_eq!(hook.writes, vec!["closed_on".to_string()]);
1860+
assert_eq!(hook.reads, vec!["updated_on".to_string()]);
1861+
}
18241862
}

crates/ogar-from-ruff/src/mint.rs

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,14 @@
2929
//! [`OpenProjectPort`](ogar_vocab::ports::OpenProjectPort) — different render
3030
//! skins, the same conceptual identity where they converge.
3131
32-
use ogar_vocab::Class;
32+
use ogar_vocab::{ActionDef, Class};
3333
use ogar_vocab::app::render_classid_for;
3434
use ogar_vocab::ports::PortSpec;
3535
use ruff_spo_address::{Facet, Mint, mint_with_classid};
3636
use ruff_spo_triplet::{ModelGraph, expand};
3737

3838
use crate::sqlalchemy::lift_model_graph_sqlalchemy;
39-
use crate::{lift_model_graph, lift_model_graph_python};
39+
use crate::{lift_actions, lift_model_graph, lift_model_graph_python};
4040

4141
/// A class compiled to its rail-shaped, language-agnostic form: the lifted
4242
/// schema ([`Class`]) plus its 16-byte address ([`Facet`]). This is what a
@@ -50,6 +50,13 @@ pub struct CompiledClass {
5050
/// The 16-byte rail facet: render classid + the `part_of` / `is_a`
5151
/// tier chains.
5252
pub facet: Facet,
53+
/// The lifted DO-arm: one [`ActionDef`] per harvested method, carrying
54+
/// the post-O-1 body facts (`reads` / `writes` / `calls`). AT-CARRY-1 of
55+
/// the W3.3 delete gate (odoo-rs `docs/W3.3-DELETE-GATE-MATRIX.md`):
56+
/// [`crate::lift_actions`] existed but was dropped here, so consumers
57+
/// never saw the behaviour arm — the THINK arm (`class`) and the DO arm
58+
/// (`actions`) now travel together, addressed by the same `facet`.
59+
pub actions: Vec<ActionDef>,
5360
}
5461

5562
/// Mint the rail facet for every node in `graph`, resolving each node's
@@ -70,17 +77,28 @@ pub fn mint_graph<P: PortSpec>(graph: &ModelGraph) -> Mint {
7077
#[must_use]
7178
pub fn compile_graph_python<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClass> {
7279
let mint = mint_graph::<P>(graph);
73-
lift_model_graph_python(graph)
80+
let classes = lift_model_graph_python(graph);
81+
// The lift maps `graph.models` 1:1 in declaration order (member-level
82+
// filter_maps only), so zipping recovers each class's source `Model` —
83+
// the carrier `lift_actions` needs (AT-CARRY-1).
84+
assert_eq!(
85+
classes.len(),
86+
graph.models.len(),
87+
"lift must map graph.models 1:1 in declaration order — a model-level \
88+
filter would silently mis-zip actions onto the wrong class"
89+
);
90+
classes
7491
.into_iter()
75-
.map(|class| {
92+
.zip(&graph.models)
93+
.map(|(class, model)| {
7694
let node = format!("{}:{}", graph.namespace, class.name);
7795
// A model always appears as a subject node (`rdf:type ObjectType`),
7896
// so it mints; the fallback covers a degenerate graph by stamping
7997
// the classid with empty tier chains (still a valid rail address).
8098
let facet = mint
8199
.facet(&node)
82100
.unwrap_or_else(|| Facet::from_parts(classid_for_node::<P>(&node), [0; 6], [0; 6]));
83-
CompiledClass { class, facet }
101+
CompiledClass { class, facet, actions: lift_actions(model) }
84102
})
85103
.collect()
86104
}
@@ -119,14 +137,23 @@ pub fn compile_graph_python<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClas
119137
#[must_use]
120138
pub fn compile_graph_sqlalchemy<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClass> {
121139
let mint = mint_graph::<P>(graph);
122-
lift_model_graph_sqlalchemy(graph)
140+
let classes = lift_model_graph_sqlalchemy(graph);
141+
// 1:1 with `graph.models` in declaration order (see compile_graph_python).
142+
assert_eq!(
143+
classes.len(),
144+
graph.models.len(),
145+
"lift must map graph.models 1:1 in declaration order — a model-level \
146+
filter would silently mis-zip actions onto the wrong class"
147+
);
148+
classes
123149
.into_iter()
124-
.map(|class| {
150+
.zip(&graph.models)
151+
.map(|(class, model)| {
125152
let node = format!("{}:{}", graph.namespace, class.name);
126153
let facet = mint
127154
.facet(&node)
128155
.unwrap_or_else(|| Facet::from_parts(classid_for_node::<P>(&node), [0; 6], [0; 6]));
129-
CompiledClass { class, facet }
156+
CompiledClass { class, facet, actions: lift_actions(model) }
130157
})
131158
.collect()
132159
}
@@ -145,14 +172,23 @@ pub fn compile_graph_sqlalchemy<P: PortSpec>(graph: &ModelGraph) -> Vec<Compiled
145172
#[must_use]
146173
pub fn compile_graph_ruby<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClass> {
147174
let mint = mint_graph::<P>(graph);
148-
lift_model_graph(graph)
175+
let classes = lift_model_graph(graph);
176+
// 1:1 with `graph.models` in declaration order (see compile_graph_python).
177+
assert_eq!(
178+
classes.len(),
179+
graph.models.len(),
180+
"lift must map graph.models 1:1 in declaration order — a model-level \
181+
filter would silently mis-zip actions onto the wrong class"
182+
);
183+
classes
149184
.into_iter()
150-
.map(|class| {
185+
.zip(&graph.models)
186+
.map(|(class, model)| {
151187
let node = format!("{}:{}", graph.namespace, class.name);
152188
let facet = mint
153189
.facet(&node)
154190
.unwrap_or_else(|| Facet::from_parts(classid_for_node::<P>(&node), [0; 6], [0; 6]));
155-
CompiledClass { class, facet }
191+
CompiledClass { class, facet, actions: lift_actions(model) }
156192
})
157193
.collect()
158194
}
@@ -219,7 +255,8 @@ mod tests {
219255
});
220256
m.functions.push(Function {
221257
name: "_compute_amount".to_string(),
222-
reads: Vec::new(),
258+
reads: vec!["line_ids.balance".to_string()],
259+
writes: vec!["amount_total".to_string()],
223260
raises: Vec::new(),
224261
traverses: Vec::new(),
225262
..Default::default()
@@ -261,6 +298,19 @@ mod tests {
261298
OdooPort::APP_PREFIX,
262299
"Odoo render prefix",
263300
);
301+
302+
// AT-CARRY-1 (W3.3 delete gate, odoo-rs docs/W3.3-DELETE-GATE-MATRIX.md):
303+
// the DO-arm travels WITH the compiled class — one ActionDef per
304+
// harvested method, body facts intact. Before this field, lift_actions
305+
// existed but compile_graph_* dropped it and no consumer ever saw the
306+
// behaviour arm.
307+
assert_eq!(cc.actions.len(), 1, "one ActionDef per harvested method");
308+
let act = &cc.actions[0];
309+
assert_eq!(act.predicate, "_compute_amount");
310+
assert_eq!(act.identity, "account_move::action_def::_compute_amount");
311+
assert_eq!(act.object_class, "account_move");
312+
assert_eq!(act.reads, vec!["line_ids.balance".to_string()]);
313+
assert_eq!(act.writes, vec!["amount_total".to_string()]);
264314
}
265315

266316
#[test]

0 commit comments

Comments
 (0)