Skip to content

Commit ab165f5

Browse files
authored
Merge pull request #132 from AdaWorldAPI/claude/odoo-rs-transcode-lf8ya5
feat(ogar): OGAR per-class transpile substrate — lift + mint + emit (+ doc)
2 parents 0a77ca6 + cb4b356 commit ab165f5

7 files changed

Lines changed: 877 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,17 @@ alignment costs. Until measured: 3×4 stands.
208208
slot to `KausalSpec`, a new lowering pass, or any other IR-surface
209209
change. The framing changes no existing decision; it changes every
210210
future one.
211+
10. `docs/OGAR-TRANSPILE-SUBSTRATE.md`**the power, in one doc.** OGAR
212+
as the bidirectional per-class transpiler: pull-in (`source →
213+
ogar-from-<lang> → ModelGraph → lift + mint → CompiledClass`),
214+
rail-facet addressing (`classid = (APP_PREFIX<<16)|concept`, the 16-byte
215+
`FacetCascade`, cross-app convergence), pull-back (runtime wrapper
216+
contract like `lance-graph-contract`, or codegen emit like
217+
`ogar-adapter-surrealql`), and the **85/15 split** (mechanical logic
218+
minted into OGAR; the "impossible" 15% = a per-language adapter +
219+
ClassView + ontological grounding). READ to understand why a consumer
220+
collapses to "a compiler-store caller + adapters, at the cost of an
221+
import." Worked example: `account.move → 0x0002_0202`.
211222
8. `docs/ARCHITECTURAL-DECISIONS-2026-06-04.md` — ADR-001..025
212223
(ADR-026 pending).
213224
9. `.claude/agents/` — the 5+3 hardening pattern (5 research savants +

crates/ogar-from-ruff/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ serde = ["dep:serde", "ogar-vocab/serde"]
1515
[dependencies]
1616
ogar-vocab = { path = "../ogar-vocab" }
1717
ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" }
18+
ruff_spo_address = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" }
1819
serde = { workspace = true, optional = true }

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

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
//! Pull-back emit — the OGAR transpile substrate's *output* leg.
2+
//!
3+
//! [`mint`](crate::mint) is the pull-in half (source → `CompiledClass`); this
4+
//! is the pull-back half: render a [`CompiledClass`] back into a target
5+
//! language's source. The reference emitter here is **Rust**
6+
//! ([`emit_rust`]); it mirrors the role `ogar-adapter-surrealql` plays for
7+
//! SurrealQL DDL. Other targets (`emit_csharp`, `emit_python`, …) follow the
8+
//! same `&CompiledClass -> String` seam.
9+
//!
10+
//! **The wrapper-contract pivot.** The emitted struct does not inline native
11+
//! types — it uses the consumer's thin wrapper-contract types: `OgScalar` for
12+
//! a column, `ToOne<T>` / `ToMany<T>` for a relation. So "the language just
13+
//! needs to put a wrapper contract akin to lance-graph" is literal: a Rust
14+
//! consumer provides those three aliases (its wrapper contract) and the
15+
//! emitted, rail-shaped class compiles. The `classid` is emitted as a `const`
16+
//! — the rail address travels with the class.
17+
//!
18+
//! Scalar attributes currently emit `OgScalar` uniformly: the Odoo field type
19+
//! (`Char` / `Monetary` / …) is not yet carried on the SPO `Field`, so there
20+
//! is nothing to specialise on. When the `field_type` capture lands (the ruff
21+
//! follow-up), `OgScalar` refines to the mapped concrete type with no change
22+
//! to this seam.
23+
24+
use ogar_vocab::AssociationKind;
25+
26+
use crate::mint::CompiledClass;
27+
28+
/// Emit a [`CompiledClass`] as Rust source: a struct whose fields use the
29+
/// consumer's wrapper-contract types (`OgScalar` / `ToOne` / `ToMany`),
30+
/// prefixed by its rail `classid` const and a facet/concept doc line.
31+
/// Computed fields are emitted as trailing doc lines (the compute behaviour
32+
/// is the "impossible" 15% — it lands as an adapter, not inline codegen).
33+
#[must_use]
34+
pub fn emit_rust(cc: &CompiledClass) -> String {
35+
let ty = pascal_case(&cc.class.name);
36+
let mut out = String::new();
37+
38+
out.push_str(&format!(
39+
"/// Rail class `{}` — classid `0x{:08X}` (concept `0x{:04X}`).\n",
40+
cc.class.name,
41+
cc.facet.facet_classid(),
42+
cc.facet.facet_classid() as u16,
43+
));
44+
out.push_str(&format!(
45+
"pub const {}_CLASSID: u32 = 0x{:08X};\n\n",
46+
screaming_snake(&cc.class.name),
47+
cc.facet.facet_classid(),
48+
));
49+
50+
out.push_str(&format!("pub struct {ty} {{\n"));
51+
for attr in &cc.class.attributes {
52+
out.push_str(&format!(" pub {}: OgScalar,\n", attr.name));
53+
}
54+
for assoc in &cc.class.associations {
55+
let target = pascal_case(assoc.class_name.as_deref().unwrap_or(&assoc.name));
56+
let field_ty = match assoc.kind {
57+
AssociationKind::BelongsTo | AssociationKind::HasOne => format!("ToOne<{target}>"),
58+
AssociationKind::HasMany | AssociationKind::HasAndBelongsToMany => {
59+
format!("ToMany<{target}>")
60+
}
61+
// A future AssociationKind defaults to a single typed reference.
62+
_ => format!("ToOne<{target}>"),
63+
};
64+
out.push_str(&format!(" pub {}: {field_ty},\n", assoc.name));
65+
}
66+
out.push_str("}\n");
67+
68+
for c in &cc.class.computed_fields {
69+
out.push_str(&format!(
70+
"// computed: {} <- {}({})\n",
71+
c.field,
72+
c.compute_method,
73+
c.depends.join(", "),
74+
));
75+
}
76+
77+
out
78+
}
79+
80+
/// `account.move` / `account_move` → `AccountMove`. Treats both `.` and `_`
81+
/// as word separators (Odoo dotted comodels and underscore-normalised model
82+
/// names both arrive here).
83+
fn pascal_case(name: &str) -> String {
84+
name.split(['.', '_'])
85+
.filter(|seg| !seg.is_empty())
86+
.map(|seg| {
87+
let mut chars = seg.chars();
88+
chars.next().map_or_else(String::new, |first| {
89+
first.to_uppercase().collect::<String>() + chars.as_str()
90+
})
91+
})
92+
.collect()
93+
}
94+
95+
/// `account_move` → `ACCOUNT_MOVE` (for the `*_CLASSID` const name).
96+
fn screaming_snake(name: &str) -> String {
97+
name.split(['.', '_'])
98+
.filter(|seg| !seg.is_empty())
99+
.map(str::to_uppercase)
100+
.collect::<Vec<_>>()
101+
.join("_")
102+
}
103+
104+
#[cfg(test)]
105+
mod tests {
106+
use super::*;
107+
use crate::mint::compile_graph_python;
108+
use ogar_vocab::ports::OdooPort;
109+
use ruff_spo_triplet::{Field, Function, Model, ModelGraph};
110+
111+
fn account_move_graph() -> ModelGraph {
112+
let mut m = Model::new("account_move");
113+
m.fields.push(Field {
114+
name: "name".to_string(),
115+
..Default::default()
116+
});
117+
m.fields.push(Field {
118+
name: "partner_id".to_string(),
119+
target: Some("res.partner".to_string()),
120+
relation_kind: Some("many2one".to_string()),
121+
..Default::default()
122+
});
123+
m.fields.push(Field {
124+
name: "line_ids".to_string(),
125+
target: Some("account.move.line".to_string()),
126+
inverse_name: Some("move_id".to_string()),
127+
relation_kind: Some("one2many".to_string()),
128+
..Default::default()
129+
});
130+
m.fields.push(Field {
131+
name: "amount_total".to_string(),
132+
emitted_by: Some("_compute_amount".to_string()),
133+
depends_on: vec!["line_ids.balance".to_string()],
134+
..Default::default()
135+
});
136+
m.functions.push(Function {
137+
name: "_compute_amount".to_string(),
138+
reads: Vec::new(),
139+
raises: Vec::new(),
140+
traverses: Vec::new(),
141+
});
142+
let mut g = ModelGraph::new("odoo");
143+
g.models.push(m);
144+
g
145+
}
146+
147+
#[test]
148+
fn emits_rail_struct_with_wrapper_contract_types() {
149+
let cc = &compile_graph_python::<OdooPort>(&account_move_graph())[0];
150+
let rust = emit_rust(cc);
151+
152+
// The rail address travels as a const.
153+
assert!(rust.contains("pub const ACCOUNT_MOVE_CLASSID: u32 = 0x00020202;"));
154+
// The struct is PascalCase.
155+
assert!(rust.contains("pub struct AccountMove {"));
156+
// Scalar -> the wrapper's OgScalar.
157+
assert!(rust.contains("pub name: OgScalar,"));
158+
// Many2one -> ToOne<comodel>; One2many -> ToMany<comodel>.
159+
assert!(
160+
rust.contains("pub partner_id: ToOne<ResPartner>,"),
161+
"got:\n{rust}"
162+
);
163+
assert!(
164+
rust.contains("pub line_ids: ToMany<AccountMoveLine>,"),
165+
"got:\n{rust}"
166+
);
167+
// Computed behaviour is a doc line (the 15% lands as an adapter).
168+
assert!(rust.contains("// computed: amount_total <- _compute_amount(line_ids.balance)"));
169+
}
170+
171+
#[test]
172+
fn pascal_case_handles_dotted_and_underscored() {
173+
assert_eq!(pascal_case("account.move.line"), "AccountMoveLine");
174+
assert_eq!(pascal_case("account_move"), "AccountMove");
175+
assert_eq!(pascal_case("res.partner"), "ResPartner");
176+
assert_eq!(screaming_snake("account_move"), "ACCOUNT_MOVE");
177+
}
178+
}

0 commit comments

Comments
 (0)