diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 8ce9379..552dd0c 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, @@ -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. @@ -94,6 +119,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 +388,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 +525,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()); @@ -648,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 980d313..025ebb7 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` @@ -175,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. @@ -186,6 +202,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`, @@ -1024,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::*; @@ -1044,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