From c60d736e535411d1df28eace0e0975ed6a78ccf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 09:28:43 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(ogar-vocab):=20Inheritance=20=E2=80=94?= =?UTF-8?q?=20agnostic=20STI/abstract/root=20metabolization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 8 of the agnostic flip, as code. Rails conflates three distinct things across parent + abstract_model + inheritance_column_disabled; this collapses them into one typed, curator-agnostic slot. - ogar-vocab: add Inheritance { Root, Concrete{parent}, Abstract, RootedAt{root} } (non_exhaustive, Default=Root, serde) + a Class.inheritance field (serde(default)). Additive: parent / abstract_model / inheritance_column_disabled retained one cycle; all existing consumers keep building unchanged. - ogar-from-ruff: lift_inheritance(model) metabolizes StiInfo — inherits_from -> Concrete; abstract_class -> Abstract; inheritance_column (no parent) -> RootedAt(self); else Root. Mixins are NOT consulted (concerns are a separate axis). Mixins/concerns stay separate (the doctrine: STI parent and 'include Journalized' are not the same organ). ogar-vocab 9, ogar-from-ruff 18 green; all 5 other vocab consumers build; clippy clean. --- crates/ogar-from-ruff/src/lib.rs | 61 +++++++++++++++++++++++++++++++- crates/ogar-vocab/src/lib.rs | 40 +++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 8ce9379..10af6a3 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -66,7 +66,7 @@ use ogar_vocab::{ ActionDef, Association, AssociationKind, Attribute, Callback, Class, EnumDecl, EnumSource, - Language, Scope, Validation, + Inheritance, Language, Scope, Validation, }; use ruff_spo_triplet::{ AssocDecl, AssocKind, AttrDecl, AttrKind, Callback as RuffCallback, ConcernKind, Model, @@ -94,6 +94,7 @@ pub fn lift_model(model: &Model) -> Class { let mut class = Class::new(&model.name); class.language = Language::Ruby; class.parent = model.sti.as_ref().and_then(sti_parent); + class.inheritance = lift_inheritance(model); class.associations = model.associations.iter().filter_map(lift_association).collect(); class.mixins = lift_mixins(model); class.attributes = model.attributes.iter().filter_map(lift_attribute).collect(); @@ -362,6 +363,27 @@ fn sti_parent(sti: &StiInfo) -> Option { sti.inherits_from.clone() } +/// Metabolize Rails STI facts into the agnostic [`Inheritance`] slot. +/// +/// Priority: a declared parent makes it an [`Inheritance::Concrete`] child +/// regardless of other flags; `abstract_class` makes it +/// [`Inheritance::Abstract`]; an `inheritance_column` with no parent makes +/// it the [`Inheritance::RootedAt`] root of a hierarchy; otherwise +/// [`Inheritance::Root`]. Mixins / concerns are NOT consulted — they are a +/// separate axis (`Class.mixins`). +fn lift_inheritance(model: &Model) -> Inheritance { + match model.sti.as_ref() { + Some(sti) if sti.inherits_from.is_some() => Inheritance::Concrete { + parent: sti.inherits_from.clone().expect("checked is_some"), + }, + Some(sti) if sti.abstract_class => Inheritance::Abstract, + Some(sti) if sti.inheritance_column.is_some() => Inheritance::RootedAt { + root: model.name.clone(), + }, + _ => Inheritance::Root, + } +} + // ───────────────────────────── helpers ────────────────────────────────── /// Strip ruby-source markers (quote pairs, leading symbol colon) from @@ -478,6 +500,43 @@ mod tests { assert!(matches!(class.language, Language::Ruby)); } + #[test] + fn lift_inheritance_concrete_from_sti_parent() { + // mk_model's StiInfo has inherits_from = Some("Issue"). + let class = lift_model(&mk_model()); + assert_eq!( + class.inheritance, + Inheritance::Concrete { parent: "Issue".to_string() }, + ); + } + + #[test] + fn lift_inheritance_abstract_rooted_and_root() { + // abstract_class → Abstract + let mut m = Model::new("ApplicationRecord"); + m.sti = Some(StiInfo { + inherits_from: None, + abstract_class: true, + inheritance_column: None, + }); + assert_eq!(lift_model(&m).inheritance, Inheritance::Abstract); + + // inheritance_column, no parent → RootedAt(self): the STI root. + let mut r = Model::new("Principal"); + r.sti = Some(StiInfo { + inherits_from: None, + abstract_class: false, + inheritance_column: Some("type".to_string()), + }); + assert_eq!( + lift_model(&r).inheritance, + Inheritance::RootedAt { root: "Principal".to_string() }, + ); + + // no STI info at all → Root. + assert_eq!(lift_model(&Model::new("Plain")).inheritance, Inheritance::Root); + } + #[test] fn lift_associations_drops_accepts_nested_and_parses_options() { let class = lift_model(&mk_model()); diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 980d313..cf1966f 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -93,6 +93,14 @@ pub struct Class { /// Superclass name as written, when one is declared. Used by /// consumers to assemble single-table-inheritance hierarchies. pub parent: Option, + /// Agnostic inheritance slot — metabolizes the three things Rails + /// conflates (STI parent / abstract base / STI root) into one typed + /// value. Mixins / concerns are a SEPARATE axis ([`Self::mixins`]) and + /// are never folded in here. `parent` / `abstract_model` / + /// `inheritance_column_disabled` are retained for one migration cycle; + /// new consumers should read `inheritance`. + #[cfg_attr(feature = "serde", serde(default))] + pub inheritance: Inheritance, /// Source language of the producer that emitted this class. pub language: Language, /// `belongs_to` / `has_one` / `has_many` / `has_and_belongs_to_many` @@ -186,6 +194,38 @@ pub struct Class { pub methods: Vec, } +/// How a class sits in its inheritance lattice — the agnostic +/// metabolization of the three things Rails conflates: STI parent, +/// abstract base, and STI root. Mixins / concerns are a SEPARATE axis +/// ([`Class::mixins`]) and are never folded in here. +/// +/// Producer IR carries parent/root as **names** (`String`); the registry +/// mints the `ClassId` later. Cross-curator mapping: Rails `< Parent` / +/// `self.abstract_class` / `self.inheritance_column`; Odoo `_inherit` / +/// `_abstract`; Django abstract base classes. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub enum Inheritance { + /// No superclass beyond the ORM root (Rails `< ApplicationRecord`). + #[default] + Root, + /// Concrete STI child of `parent` (shares the parent's table). + Concrete { + /// Parent class name as written. + parent: String, + }, + /// Abstract base — methods / fields inherited, but no table of its own + /// (Rails `self.abstract_class = true`; Odoo `_abstract = True`). + Abstract, + /// Root of an STI hierarchy — defines the discriminator column but is + /// not itself a child. `root` is this class's own name. + RootedAt { + /// The hierarchy root class name (this class). + root: String, + }, +} + /// A computed-field declaration. Carries the field name, the compute /// method's symbol, and the dependency list from `@api.depends`. /// Universal across ORMs: Odoo `compute='_compute_x'` + `@api.depends`, From 4b24d035c32b835db12862be65c2df91f863e855 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 09:33:58 +0000 Subject: [PATCH 2/3] feat(ogar-vocab): name the curator domain (OpenProject=project, Odoo=erp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One tiny classifier, not a metabolization: tag each lifted Class with its curator domain so the agnostic shape carries 'what kind of system' without leaking the namespace. - ogar-vocab: add Class.source_domain: Option (serde default) — a coarse curator-agnostic tag and a ClassFingerprint component. - ogar-from-ruff: classify_domain(namespace) maps openproject/redmine -> 'project', odoo -> 'erp', else None (no guessing); lift_model_graph tags every class from graph.namespace. ogar-vocab 9, ogar-from-ruff 19 green; clippy clean; additive. --- crates/ogar-from-ruff/src/lib.rs | 43 +++++++++++++++++++++++++++++++- crates/ogar-vocab/src/lib.rs | 8 ++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 10af6a3..552dd0c 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -78,7 +78,32 @@ use ruff_spo_triplet::{ /// deterministic ordering for snapshot tests. #[must_use] pub fn lift_model_graph(graph: &ModelGraph) -> Vec { - graph.models.iter().map(lift_model).collect() + let domain = classify_domain(&graph.namespace); + graph + .models + .iter() + .map(|m| { + let mut class = lift_model(m); + class.source_domain = domain.clone(); + class + }) + .collect() +} + +/// Name the curator **domain** from the harvest namespace — the "one tiny +/// regex" that tags OpenProject as a `project` domain and Odoo as an `erp` +/// domain. A coarse, curator-agnostic label (a `ClassFingerprint` +/// component), not the namespace itself. Returns `None` for unrecognized +/// namespaces — the domain stays unset rather than guessed. +fn classify_domain(namespace: &str) -> Option { + let ns = namespace.to_ascii_lowercase(); + if ns.contains("openproject") || ns.contains("redmine") { + Some("project".to_string()) + } else if ns.contains("odoo") { + Some("erp".to_string()) + } else { + None + } } /// Lift one [`Model`] to an OGAR [`Class`]. Pure projection — no I/O. @@ -707,6 +732,22 @@ mod tests { assert_eq!(classes[1].name, "Project"); } + #[test] + fn classify_domain_names_op_project_and_odoo_erp() { + // OpenProject → "project" + let mut op = ModelGraph::new("openproject"); + op.models.push(Model::new("WorkPackage")); + assert_eq!(lift_model_graph(&op)[0].source_domain.as_deref(), Some("project")); + // Odoo → "erp" + let mut odoo = ModelGraph::new("odoo"); + odoo.models.push(Model::new("AccountMove")); + assert_eq!(lift_model_graph(&odoo)[0].source_domain.as_deref(), Some("erp")); + // Unrecognized → None (not guessed). + let mut other = ModelGraph::new("mystery"); + other.models.push(Model::new("X")); + assert_eq!(lift_model_graph(&other)[0].source_domain, None); + } + fn mk_model_with_functions() -> Model { let mut m = Model::new("WorkPackage"); m.functions.push(Function { diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index cf1966f..26bd06f 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -183,6 +183,14 @@ pub struct Class { /// Source language major version (`"17.0"`, `"7.2"`, ...) for /// multi-version source compatibility. Reserved; v1 leaves `None`. pub source_version: Option, + /// Curator **domain** — the kind of system this class was harvested + /// from: `"project"` (OpenProject / Redmine), `"erp"` (Odoo / SAP), … + /// A coarse, curator-agnostic tag (NOT the namespace or module) and a + /// component of the `ClassFingerprint` used to mint a stable `ClassId`. + /// Set by the frontend from the harvest namespace; `None` when + /// unrecognized. + #[cfg_attr(feature = "serde", serde(default))] + pub source_domain: Option, /// Computed-field declarations (Odoo `compute=...` fields, also /// Rails / Django where producers can detect them). Lives in /// base vocab — see `docs/ODOO-TRANSCODING.md` §8. From 93785785224ae2fc6155fd039ddf8c3228dc527e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 09:39:46 +0000 Subject: [PATCH 3/3] =?UTF-8?q?feat(ogar-vocab):=20wire=5Fsynergies=20?= =?UTF-8?q?=E2=80=94=20cross-domain=20concept=20bridges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once domains are named, wire the synergies: the same canonical concept surfacing in 2+ domains (OpenProject User <-> Odoo res.users => concept 'user') is what makes the unified vocab worth more than its curators. - Synergy { concept, members: [SynergyMember{domain, class_name}] }. - wire_synergies(&[Class]) groups by canonical_concept, keeps only concepts spanning 2+ distinct source_domains; deterministic order; undomained classes skipped. - canonical_concept: lowercase, last dotted segment (res.users->users), drop a trailing plural s except after ss. Coarse v1 bridge, not a thesaurus. ogar-vocab 11 green; clippy clean. --- crates/ogar-vocab/src/lib.rs | 115 +++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 26bd06f..025ebb7 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1072,6 +1072,89 @@ impl Validation { } } +// ───────────────────────────────────────────────────────────────────── +// Cross-domain synergies +// ───────────────────────────────────────────────────────────────────── + +/// A cross-domain **synergy**: one canonical concept that surfaces in two +/// or more curator [domains](Class::source_domain) — e.g. `user` in both +/// the `project` domain (OpenProject `User`) and the `erp` domain (Odoo +/// `res.users`). Wiring synergies is what makes the agnostic vocab more +/// than the sum of its curators: shared concepts unify across domains. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub struct Synergy { + /// Canonical concept (normalized class name) the members share. + pub concept: String, + /// The classes that realize this concept — one entry per domain that + /// has it, ordered by domain. + pub members: Vec, +} + +/// One domain's realization of a [`Synergy`] concept. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[non_exhaustive] +pub struct SynergyMember { + /// The curator domain (`"project"`, `"erp"`, …) — see + /// [`Class::source_domain`]. + pub domain: String, + /// The class name as written in that domain. + pub class_name: String, +} + +/// Wire cross-domain synergies across a set of lifted [`Class`]es. +/// +/// Groups classes by [`canonical_concept`] and keeps only concepts that +/// appear in **2+ distinct** [`source_domain`](Class::source_domain)s — +/// those bridges are the synergies. Classes with no `source_domain` are +/// skipped (a synergy needs domains to bridge); the first class seen per +/// (concept, domain) wins. Output is deterministic (ordered by concept, +/// then domain). +#[must_use] +pub fn wire_synergies(classes: &[Class]) -> Vec { + use std::collections::BTreeMap; + let mut by_concept: BTreeMap> = BTreeMap::new(); + for c in classes { + let Some(domain) = c.source_domain.as_ref() else { + continue; + }; + let concept = canonical_concept(&c.name); + by_concept + .entry(concept) + .or_default() + .entry(domain.clone()) + .or_insert_with(|| c.name.clone()); + } + by_concept + .into_iter() + .filter(|(_, domains)| domains.len() >= 2) + .map(|(concept, domains)| Synergy { + concept, + members: domains + .into_iter() + .map(|(domain, class_name)| SynergyMember { domain, class_name }) + .collect(), + }) + .collect() +} + +/// Normalize a class name to its canonical concept: lowercase, take the +/// last dotted segment (Odoo `res.users` → `users`), and drop a single +/// trailing plural `s` (`users` → `user`), except after `ss`. Coarse by +/// design — a v1 bridge for obvious overlaps (`user`, `project`, +/// `company`), not a thesaurus. +fn canonical_concept(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + let last = lower.rsplit('.').next().unwrap_or(lower.as_str()); + if last.len() > 3 && last.ends_with('s') && !last.ends_with("ss") { + last[..last.len() - 1].to_string() + } else { + last.to_string() + } +} + #[cfg(test)] mod tests { use super::*; @@ -1092,6 +1175,38 @@ mod tests { assert!(c.associations.is_empty()); } + #[test] + fn wire_synergies_links_a_concept_across_domains() { + let mut op_user = Class::new("User"); + op_user.source_domain = Some("project".to_string()); + let mut odoo_user = Class::new("res.users"); + odoo_user.source_domain = Some("erp".to_string()); + let mut op_wp = Class::new("WorkPackage"); + op_wp.source_domain = Some("project".to_string()); + + let syn = wire_synergies(&[op_user, odoo_user, op_wp]); + assert_eq!(syn.len(), 1, "only `user` bridges both domains"); + assert_eq!(syn[0].concept, "user"); + assert_eq!(syn[0].members.len(), 2); + // ordered by domain: erp before project + assert_eq!(syn[0].members[0].domain, "erp"); + assert_eq!(syn[0].members[0].class_name, "res.users"); + assert_eq!(syn[0].members[1].domain, "project"); + assert_eq!(syn[0].members[1].class_name, "User"); + } + + #[test] + fn wire_synergies_needs_two_distinct_domains() { + // same concept, same domain → not a synergy + let mut a = Class::new("User"); + a.source_domain = Some("project".to_string()); + let mut b = Class::new("Users"); + b.source_domain = Some("project".to_string()); + // an undomained class is ignored entirely + let c = Class::new("res.users"); + assert!(wire_synergies(&[a, b, c]).is_empty()); + } + #[test] fn elixir_language_is_a_distinct_first_class_variant() { // The OLD HIRO/Bardioc stack is Elixir; it is a first-class source