Skip to content

Commit 8d70a56

Browse files
authored
Merge pull request #144 from AdaWorldAPI/claude/odoo-rs-transcode-lf8ya5
canon + feat: E-KEEP-AR-REMOVE-ORM + compile_graph_ruby (the Rails CompiledClass keystone)
2 parents ed79365 + 47275d2 commit 8d70a56

5 files changed

Lines changed: 270 additions & 76 deletions

File tree

.claude/board/EPIPHANIES.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@
77
88
---
99

10+
## 2026-06-30 — E-KEEP-AR-REMOVE-ORM — the consumer open-heart op KEEPS ActiveRecord and removes the ORM; OGAR is named after AR
11+
12+
**Status:** FRAMING (`[G]` for the name origin + the keep/remove split, operator-stated 2026-06-30; the convergence wiring it implies is `[H]`, gated per the OP assessment). Corrects a session inversion (mine) that read the consumer pivot as "castrate the hand-rolled Rails ActiveRecord betrayal" — **backwards**.
13+
14+
**The correction (operator):** *"We don't remove Active Record — that's exactly what we keep. We just do an open-heart operation to remove the ORM and wire pure AR on Rails. Hence OGAR (Open Graph Active Record) — the name was literally inspired by AR in OpenProject."*
15+
16+
- **KEEP — the ActiveRecord pattern.** A class IS a record + behavior (associations / validations / callbacks / STI). This is the domain model and it is literally what OGAR's `Class`/`ClassView` represents. **OGAR = Open Graph Active Record**, named after AR in OpenProject. The ClassView IS the active record.
17+
- **REMOVE — the ORM.** The hand-rolled persistence plumbing between AR and the DB. In `openproject-nexgen-rs` that is `op-db`'s hand-typed SQL repos + `FromRow` rows (9009 LOC) and `op-api`'s hand-mapped row→DTO (8592 LOC). An ORM re-implemented by hand IS the "betrayal" — **never AR itself.**
18+
- **WIRE — "pure AR on Rails":** the AR domain model backed **directly by the OGAR graph** (classid-keyed node + ClassView = the record; persistence = the graph via OGAR emit), no ORM intermediary. The "open-heart operation": excise the ORM organ, keep the AR heart, re-plumb AR onto OGAR.
19+
20+
**Redmine-as-root.** OpenProject is a Redmine→ChiliProject→OpenProject fork; Redmine's cleaner ancestral AR (ERB **fieldview**) defines the canonical ClassView, OP (which accreted the hand-rolled ORM on top) converges onto it. **fieldview/erb → classview/askama** = the AR view layer becomes the OGAR ClassView rendered via askama — the render *skin* over the AR *substance* ("ice caking"). The hi/lo classid split already encodes it: lo-u16 = shared concept (Redmine≡OP, machine-checked 26/26), hi-u16 = per-app render skin.
21+
22+
**Guard (do not re-invert):** the operation is ORM-out / AR-in. Removing or re-deriving the ActiveRecord domain model is the inversion this entry exists to prevent. AR stays throughout; only the ORM and (later, gated) the SPO-corpus/native emit paths are subtractive — additive-then-subtractive, never the reverse.
23+
24+
**Cross-ref:** the 6-agent assessment (RESONATES/QUALIFIED) + the concrete additive increment (OGAR `compile_graph_ruby` ~15 LOC → OP `ogar-emit` Stage-B alongside the ORM → Redmine↔OP convergence pin) is captured in `openproject-nexgen-rs/.claude/handovers/2026-06-30-1200-op-redmine-ogar-convergence-assessment.md`. Doctrine home: `docs/OGAR-CONSUMER-BEST-PRACTICES.md` (classid-is-address / magic-at-resolution); `docs/OGAR-TRANSPILE-SUBSTRATE.md` (the 85/15 pull-in/pull-back). The OGAR-side keystone gap: a Rails `compile_graph_ruby` (today only `compile_graph_python` exists in `crates/ogar-from-ruff/src/mint.rs`).
25+
26+
---
27+
1028
## 2026-06-30 — E-ACCIDENTAL-IMPERATIVE — the hand-rolled residue = accidentally-imperative (AR verbs on AR targets, no declarative home) ∪ essentially-foreign; the body pass TRIAGES, it does not decompile
1129

1230
**Status:** CONJECTURE (`[H]` — the split is operator-reasoned + grounded in the Odoo↔Rails asymmetry; the ratio is unmeasured pending the body pass). Operator-directed 2026-06-30. Builds on E-FUNCTION-CATALOG. **Update 2026-06-30:** the F17 prerequisite SHIPPED — ruff now captures per-function `writes`/`calls` (AdaWorldAPI/ruff @ `claude/odoo-rs-transcode-lf8ya5`, commit `dd70588`): `ruff_spo_triplet::Function` gains `writes`+`calls`, the closed predicate vocab gains `writes_field` (Authoritative) + `calls` (Inferred), and the `ruff_ruby_spo` body walker populates both from the Rails AST (closed `AR_MUTATORS` set; `self.x=`→write, mutator dispatch→call, else read). The "NOT writes" blocker in the Falsifier below is **cleared**; F17 is RUNNABLE — the body-triage probe is the next deliverable. Ratio still unmeasured.

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

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,21 +323,43 @@ fn pascal_case(name: &str) -> String {
323323
.collect()
324324
}
325325

326-
/// `account_move` → `ACCOUNT_MOVE` (for the `*_CLASSID` const name).
326+
/// `account_move` → `ACCOUNT_MOVE`; `WorkPackage` → `WORK_PACKAGE` (for the
327+
/// `*_CLASSID` const name). Splits on `.`/`_` (Odoo's dotted/underscored
328+
/// names) AND on a lower→upper case transition (Rails' bare PascalCase class
329+
/// names carry no separator at all — `screaming_snake` must still find the
330+
/// word boundary, or every Rails-sourced const collapses to one run-on word
331+
/// like `WORKPACKAGE_CLASSID`). Does not split consecutive uppercase runs
332+
/// (acronyms): `HTTPServer` → `HTTPSERVER` — no Rails/Odoo class name in the
333+
/// corpus is acronym-prefixed, so this is a deliberately narrow rule, not a
334+
/// general camelCase tokenizer.
327335
fn screaming_snake(name: &str) -> String {
328-
name.split(['.', '_'])
329-
.filter(|seg| !seg.is_empty())
330-
.map(str::to_uppercase)
331-
.collect::<Vec<_>>()
332-
.join("_")
336+
let mut out = String::new();
337+
let mut prev_lower = false;
338+
for ch in name.chars() {
339+
if ch == '.' || ch == '_' {
340+
if !out.is_empty() && !out.ends_with('_') {
341+
out.push('_');
342+
}
343+
prev_lower = false;
344+
continue;
345+
}
346+
if ch.is_uppercase() && prev_lower {
347+
out.push('_');
348+
}
349+
out.extend(ch.to_uppercase());
350+
prev_lower = ch.is_lowercase();
351+
}
352+
out
333353
}
334354

335355
#[cfg(test)]
336356
mod tests {
337357
use super::*;
338358
use crate::mint::compile_graph_python;
339359
use ogar_vocab::ports::OdooPort;
340-
use ruff_spo_triplet::{Field, Function, Model, ModelGraph};
360+
use ruff_spo_triplet::{
361+
AssocDecl, AssocKind, AttrDecl, AttrKind, Field, Function, Model, ModelGraph,
362+
};
341363

342364
fn account_move_graph() -> ModelGraph {
343365
let mut m = Model::new("account_move");
@@ -383,6 +405,7 @@ mod tests {
383405
reads: Vec::new(),
384406
raises: Vec::new(),
385407
traverses: Vec::new(),
408+
..Default::default()
386409
});
387410
let mut g = ModelGraph::new("odoo");
388411
g.models.push(m);
@@ -419,6 +442,79 @@ mod tests {
419442
assert!(rust.contains("// computed: amount_total <- _compute_amount(line_ids.balance)"));
420443
}
421444

445+
// ───── Rails (compile_graph_ruby) — the convergence proof ─────
446+
//
447+
// The pull-back codegen leg (emit_rust/csharp/python) was, before this
448+
// session, only ever exercised on an Odoo-lifted `CompiledClass`. This
449+
// fixture proves the SAME emitters run unmodified on a Rails-lifted one
450+
// (compile_graph_ruby, ruff#38 + this crate's `mint::compile_graph_ruby`),
451+
// closing the "unproven on Rails" gap named in the OP+Redmine convergence
452+
// handover (openproject-nexgen-rs .claude/handovers/
453+
// 2026-06-30-1200-op-redmine-ogar-convergence-assessment.md §4 step 2).
454+
455+
fn work_package_rail_graph() -> ModelGraph {
456+
let mut m = Model::new("WorkPackage");
457+
m.attributes.push(AttrDecl {
458+
kind: AttrKind::Attribute,
459+
name: "estimated_hours".to_string(),
460+
options: vec![("type".to_string(), "integer".to_string())],
461+
});
462+
// No "type" option → the OgScalar fallback path, same as Odoo's
463+
// `narration` case.
464+
m.attributes.push(AttrDecl {
465+
kind: AttrKind::Attribute,
466+
name: "subject".to_string(),
467+
options: vec![],
468+
});
469+
m.associations.push(AssocDecl {
470+
kind: AssocKind::BelongsTo,
471+
name: "project".to_string(),
472+
options: vec![("class_name".to_string(), "\"Project\"".to_string())],
473+
});
474+
m.associations.push(AssocDecl {
475+
kind: AssocKind::HasMany,
476+
name: "time_entries".to_string(),
477+
options: vec![],
478+
});
479+
let mut g = ModelGraph::new("openproject");
480+
g.models.push(m);
481+
g
482+
}
483+
484+
#[test]
485+
fn emits_rust_struct_for_rails_lifted_class() {
486+
use crate::mint::compile_graph_ruby;
487+
use ogar_vocab::ports::OpenProjectPort;
488+
489+
let cc = &compile_graph_ruby::<OpenProjectPort>(&work_package_rail_graph())[0];
490+
let rust = emit_rust(cc);
491+
492+
assert!(
493+
rust.contains("pub const WORK_PACKAGE_CLASSID: u32 = 0x00010102;"),
494+
"got:\n{rust}"
495+
); // exercises the screaming_snake PascalCase fix below
496+
assert!(rust.contains("pub struct WorkPackage {"));
497+
// Rails `attribute :estimated_hours, :integer` -> OgInt (the same
498+
// og_scalar_type table Odoo's `integer` constructor maps through —
499+
// shared vocabulary across producers, per §1.6).
500+
assert!(rust.contains("pub estimated_hours: OgInt,"), "got:\n{rust}");
501+
// Untyped attribute -> OgScalar fallback.
502+
assert!(rust.contains("pub subject: OgScalar,"), "got:\n{rust}");
503+
// belongs_to with class_name override -> ToOne<Project>, not
504+
// ToOne<Project> from the (singular) relation name by coincidence —
505+
// assert the class_name path specifically by using a relation name
506+
// that would pascal_case differently if class_name were ignored.
507+
assert!(
508+
rust.contains("pub project: ToOne<Project>,"),
509+
"got:\n{rust}"
510+
);
511+
// has_many, no class_name -> pascal_case(time_entries) = TimeEntries.
512+
assert!(
513+
rust.contains("pub time_entries: ToMany<TimeEntries>,"),
514+
"got:\n{rust}"
515+
);
516+
}
517+
422518
#[test]
423519
fn emits_csharp_record_with_wrapper_contract_types() {
424520
let cc = &compile_graph_python::<OdooPort>(&account_move_graph())[0];
@@ -535,6 +631,19 @@ mod tests {
535631
assert_eq!(screaming_snake("account_move"), "ACCOUNT_MOVE");
536632
}
537633

634+
#[test]
635+
fn screaming_snake_splits_bare_pascal_case_rails_names() {
636+
// Rails class names carry no separator at all (no dots, no
637+
// underscores) — screaming_snake must find the word boundary from
638+
// case alone, or every Rails const collapses to one run-on word.
639+
assert_eq!(screaming_snake("WorkPackage"), "WORK_PACKAGE");
640+
assert_eq!(screaming_snake("TimeEntry"), "TIME_ENTRY");
641+
// Already-snake input is unaffected (the original behaviour).
642+
assert_eq!(screaming_snake("account.move.line"), "ACCOUNT_MOVE_LINE");
643+
// A single PascalCase word with no internal boundary stays whole.
644+
assert_eq!(screaming_snake("Project"), "PROJECT");
645+
}
646+
538647
#[test]
539648
fn og_scalar_type_maps_odoo_constructors() {
540649
assert_eq!(og_scalar_type(Some("char")), "OgStr");

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1334,12 +1334,14 @@ mod tests {
13341334
reads: vec!["status".to_string()],
13351335
raises: Vec::new(),
13361336
traverses: Vec::new(),
1337+
..Default::default()
13371338
});
13381339
m.functions.push(Function {
13391340
name: "close!".to_string(),
13401341
reads: Vec::new(),
13411342
raises: vec!["ArgumentError".to_string()],
13421343
traverses: Vec::new(),
1344+
..Default::default()
13431345
});
13441346
m
13451347
}

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

Lines changed: 114 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ use ogar_vocab::Class;
3434
use ruff_spo_address::{mint_with_classid, Facet, Mint};
3535
use ruff_spo_triplet::{expand, ModelGraph};
3636

37-
use crate::lift_model_graph_python;
37+
use crate::{lift_model_graph, lift_model_graph_python};
3838

3939
/// A class compiled to its rail-shaped, language-agnostic form: the lifted
4040
/// schema ([`Class`]) plus its 16-byte address ([`Facet`]). This is what a
@@ -83,6 +83,32 @@ pub fn compile_graph_python<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClas
8383
.collect()
8484
}
8585

86+
/// Compile a Ruby/Rails [`ModelGraph`] into rail-shaped [`CompiledClass`]es:
87+
/// lift each model's schema ([`Language::Ruby`](ogar_vocab::Language), via
88+
/// [`lift_model_graph`] — **not** [`lift_model_graph_python`], which would
89+
/// mis-stamp the producer language) and pair it with its minted facet,
90+
/// classid via port `P`. Declaration order is preserved (mirrors
91+
/// [`lift_model_graph`]). The Rails counterpart of
92+
/// [`compile_graph_python`] — same mint, same assembly shape, the only
93+
/// difference is which lift stamps the [`Class::language`](ogar_vocab::Class)
94+
/// field. E.g. `openproject:WorkPackage` → `0x0001_0102`,
95+
/// `redmine:Issue` → `0x0007_0102` (same shared concept, different render
96+
/// prefix — the two-render-skins-one-concept convergence).
97+
#[must_use]
98+
pub fn compile_graph_ruby<P: PortSpec>(graph: &ModelGraph) -> Vec<CompiledClass> {
99+
let mint = mint_graph::<P>(graph);
100+
lift_model_graph(graph)
101+
.into_iter()
102+
.map(|class| {
103+
let node = format!("{}:{}", graph.namespace, class.name);
104+
let facet = mint
105+
.facet(&node)
106+
.unwrap_or_else(|| Facet::from_parts(classid_for_node::<P>(&node), [0; 6], [0; 6]));
107+
CompiledClass { class, facet }
108+
})
109+
.collect()
110+
}
111+
86112
/// Resolve a node IRI's full render classid via port `P`.
87113
///
88114
/// The IRI is `<ns>:<model>` or `<ns>:<model>.<member>`; members inherit
@@ -110,8 +136,9 @@ fn model_of(node: &str) -> &str {
110136
#[cfg(test)]
111137
mod tests {
112138
use super::*;
113-
use ogar_vocab::ports::{OdooPort, OpenProjectPort};
114-
use ruff_spo_triplet::{Field, Function, Model};
139+
use ogar_vocab::Language;
140+
use ogar_vocab::ports::{OdooPort, OpenProjectPort, RedminePort};
141+
use ruff_spo_triplet::{AssocDecl, AssocKind, Field, Function, Model};
115142

116143
// A representative `account.move` `ModelGraph`, constructed directly (the
117144
// source→ModelGraph parse is `ruff_python_spo`'s job, tested there). Carries
@@ -146,6 +173,7 @@ mod tests {
146173
reads: Vec::new(),
147174
raises: Vec::new(),
148175
traverses: Vec::new(),
176+
..Default::default()
149177
});
150178
let mut g = ModelGraph::new("odoo");
151179
g.models.push(m);
@@ -223,4 +251,87 @@ mod tests {
223251
let facet = mint.facet("odoo:ir_cron").expect("node mints");
224252
assert_eq!(facet.facet_classid(), 0, "unmapped -> bootstrap address");
225253
}
254+
255+
// ───── compile_graph_ruby (Rails: the OP/Redmine convergence path) ─────
256+
257+
/// A representative `WorkPackage` `ModelGraph`, constructed directly (the
258+
/// source→ModelGraph parse is `ruff_ruby_spo`'s job, tested there).
259+
/// Carries an association so the schema-arm projects something besides
260+
/// a bare name.
261+
fn work_package_graph(namespace: &str, model_name: &str) -> ModelGraph {
262+
let mut m = Model::new(model_name);
263+
m.associations.push(AssocDecl {
264+
kind: AssocKind::BelongsTo,
265+
name: "project".to_string(),
266+
options: Vec::new(),
267+
});
268+
m.functions.push(Function {
269+
name: "update_status".to_string(),
270+
..Default::default()
271+
});
272+
let mut g = ModelGraph::new(namespace);
273+
g.models.push(m);
274+
g
275+
}
276+
277+
#[test]
278+
fn compile_graph_ruby_stamps_language_ruby_not_python() {
279+
// The bug compile_graph_python's own doc-comment warns against:
280+
// calling the Python lift on a Rails graph mis-stamps the producer
281+
// language. compile_graph_ruby must route through `lift_model_graph`
282+
// (Language::Ruby), never `lift_model_graph_python`.
283+
let graph = work_package_graph("openproject", "WorkPackage");
284+
let compiled = compile_graph_ruby::<OpenProjectPort>(&graph);
285+
assert_eq!(compiled.len(), 1);
286+
assert_eq!(compiled[0].class.language, Language::Ruby);
287+
}
288+
289+
#[test]
290+
fn openproject_work_package_compiles_to_project_work_item_rail_class() {
291+
let graph = work_package_graph("openproject", "WorkPackage");
292+
let compiled = compile_graph_ruby::<OpenProjectPort>(&graph);
293+
assert_eq!(compiled.len(), 1);
294+
let cc = &compiled[0];
295+
296+
assert_eq!(cc.class.name, "WorkPackage");
297+
assert!(
298+
!cc.class.associations.is_empty(),
299+
"belongs_to :project projects into an association"
300+
);
301+
302+
// OpenProject prefix 0x0001 | project_work_item concept 0x0102.
303+
assert_eq!(cc.facet.facet_classid(), 0x0001_0102);
304+
assert_eq!(cc.facet.facet_classid() & 0xFFFF, 0x0102, "shared concept");
305+
assert_eq!(
306+
(cc.facet.facet_classid() >> 16) as u16,
307+
OpenProjectPort::APP_PREFIX,
308+
"OpenProject render prefix",
309+
);
310+
}
311+
312+
#[test]
313+
fn openproject_and_redmine_compile_to_the_same_concept_different_render_skin() {
314+
// The literal "one canonical concept, two render skins" proof: the
315+
// SAME Rails-shaped ModelGraph (WorkPackage / Issue) minted through
316+
// each fork's port converges on the identical low-u16 concept and
317+
// diverges only on the high-u16 app-render prefix.
318+
let op_graph = work_package_graph("openproject", "WorkPackage");
319+
let rm_graph = work_package_graph("redmine", "Issue");
320+
321+
let op_compiled = compile_graph_ruby::<OpenProjectPort>(&op_graph);
322+
let rm_compiled = compile_graph_ruby::<RedminePort>(&rm_graph);
323+
324+
let op_id = op_compiled[0].facet.facet_classid();
325+
let rm_id = rm_compiled[0].facet.facet_classid();
326+
327+
assert_eq!(op_id & 0xFFFF, rm_id & 0xFFFF, "shared concept converges");
328+
assert_eq!(op_id & 0xFFFF, 0x0102, "project_work_item");
329+
assert_ne!(
330+
(op_id >> 16) as u16,
331+
(rm_id >> 16) as u16,
332+
"render prefixes diverge (OpenProject vs Redmine skin)"
333+
);
334+
assert_eq!((op_id >> 16) as u16, OpenProjectPort::APP_PREFIX);
335+
assert_eq!((rm_id >> 16) as u16, RedminePort::APP_PREFIX);
336+
}
226337
}

0 commit comments

Comments
 (0)