From abb2efefdf95b4924af3515316b7d0f607874458 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 14:50:40 +0000 Subject: [PATCH] feat: source_curator tagging via ogar-from-rails::extract_with (consumes ruff#27) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the ruff#27 thread end to end. ruff_ruby_spo::extract_with(path, namespace) lets a harvest be tagged with its specific curator instead of the hardcoded 'openproject'. This wires it through OGAR: - ogar-vocab: Class.source_curator: Option — the SPECIFIC product (openproject/redmine/odoo/osb), distinct from the coarse source_domain (project/erp). Two curators in one domain (Redmine + OpenProject are both project) stay distinguishable. Additive field (serde default); all consumers still build. - ogar-from-ruff: lift_model_graph sets source_curator from graph.namespace (empty namespace -> None, not empty string). - ogar-from-rails: extract_with(source_tree, curator) threads through ruff_ruby_spo::extract_with; plain extract() stays the OpenProject default. Tests: - ogar-from-ruff source_curator_carries_namespace_distinct_from_domain: redmine + openproject both project-domain, distinct curators, SAME canonical id (convergence holds while provenance stays separable). - ogar-from-rails real-corpus extract_with_tags_distinct_curators_in_one_- domain: Redmine Issue (curator redmine) + OP WorkPackage (curator openproject) both -> project_work_item id, distinct curators. ogar-vocab 41, ogar-from-ruff 25 (+1), real-corpus 14 (+1); clippy clean; consumers build. --- crates/ogar-from-rails/src/lib.rs | 62 +++++++++++++++++++++++++++++++ crates/ogar-from-ruff/src/lib.rs | 39 +++++++++++++++++++ crates/ogar-vocab/src/lib.rs | 9 +++++ 3 files changed, 110 insertions(+) diff --git a/crates/ogar-from-rails/src/lib.rs b/crates/ogar-from-rails/src/lib.rs index d598d28..ba03cdd 100644 --- a/crates/ogar-from-rails/src/lib.rs +++ b/crates/ogar-from-rails/src/lib.rs @@ -61,6 +61,24 @@ pub fn extract(source_tree: &Path) -> Vec { ogar_from_ruff::lift_model_graph(&graph) } +/// Extract from a Rails source tree, tagging the harvest with an explicit +/// `curator` namespace (e.g. `"redmine"`, `"openproject"`, `"osb"`). +/// +/// Threads through [`ruff_ruby_spo::extract_with`] (added in +/// AdaWorldAPI/ruff#27) so the produced classes carry the correct +/// [`Class::source_curator`] — instead of the hardcoded `"openproject"` +/// default [`extract`] inherits. `source_domain` is then derived from the +/// curator namespace ([`ogar_from_ruff::classify_domain`]): Redmine and +/// OpenProject both land in `project`, but stay distinguishable by curator. +/// +/// Use this for any non-OpenProject Rails curator; plain [`extract`] +/// remains the OpenProject-default convenience. +#[must_use] +pub fn extract_with(source_tree: &Path, curator: &str) -> Vec { + let graph = ruff_ruby_spo::extract_with(source_tree, curator); + ogar_from_ruff::lift_model_graph(&graph) +} + #[cfg(test)] mod tests { use super::*; @@ -73,6 +91,50 @@ mod tests { fn nonexistent_source_tree_yields_empty_vec() { let classes = extract(Path::new("/tmp/__definitely_does_not_exist_for_test__")); assert!(classes.is_empty(), "no app/models → no classes"); + // extract_with mirrors the no-panic behaviour too. + let classes = extract_with(Path::new("/tmp/__nope__"), "redmine"); + assert!(classes.is_empty()); + } + + /// **Per-curator namespace tagging** on real source (the ruff#27 + /// `extract_with` thread, end to end). Redmine and OpenProject are + /// both `project`-domain but distinct curators: `extract_with` tags + /// each correctly via [`Class::source_curator`], while the canonical + /// concept still converges (`Issue` and `WorkPackage` → the same + /// `project_work_item` id). + #[test] + #[ignore = "requires Redmine + OpenProject checkouts"] + fn extract_with_tags_distinct_curators_in_one_domain() { + let Ok(redmine_src) = std::env::var("REDMINE_SRC") else { + eprintln!("skipping: REDMINE_SRC not set"); + return; + }; + let op_src = std::env::var("OPENPROJECT_SRC") + .unwrap_or_else(|_| "/home/user/openproject".to_string()); + let op_path = PathBuf::from(&op_src); + if !op_path.exists() { + eprintln!("skipping: OpenProject not present at {op_src}"); + return; + } + + let redmine = extract_with(&PathBuf::from(redmine_src), "redmine"); + let openproject = extract_with(&op_path, "openproject"); + + let issue = redmine.iter().find(|c| c.name == "Issue").unwrap(); + let wp = openproject.iter().find(|c| c.name == "WorkPackage").unwrap(); + + // Distinct curators ... + assert_eq!(issue.source_curator.as_deref(), Some("redmine")); + assert_eq!(wp.source_curator.as_deref(), Some("openproject")); + // ... same domain ... + assert_eq!(issue.source_domain.as_deref(), Some("project")); + assert_eq!(wp.source_domain.as_deref(), Some("project")); + // ... and the canonical concept still converges. + assert_eq!(issue.canonical_id(), wp.canonical_id()); + assert_eq!( + issue.canonical_id(), + ogar_vocab::canonical_concept_id("project_work_item"), + ); } /// Smoke test against the live OpenProject source tree on the dev diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index f5a99f6..e8fe38b 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -80,12 +80,23 @@ use ruff_spo_triplet::{ pub fn lift_model_graph(graph: &ModelGraph) -> Vec { let domain = classify_domain(&graph.namespace); let concept_domain = domain.as_deref().and_then(ogar_vocab::source_domain_concept); + // The harvest namespace IS the curator id (`"openproject"`, + // `"redmine"`, `"odoo"`, …). `source_domain` is the coarse bucket it + // maps to; `source_curator` keeps the specific product so two curators + // in the same domain (Redmine + OpenProject are both `project`) stay + // distinguishable downstream. + let curator = if graph.namespace.is_empty() { + None + } else { + Some(graph.namespace.clone()) + }; graph .models .iter() .map(|m| { let mut class = lift_model(m); class.source_domain = domain.clone(); + class.source_curator = curator.clone(); // Domain-gate the canonical concept. `lift_model` resolves // domain-blind (an all-domains best guess); here we know the // curator's domain, so re-resolve through the gate to withhold a @@ -908,6 +919,34 @@ mod tests { ); } + #[test] + fn source_curator_carries_namespace_distinct_from_domain() { + // Two curators in the SAME domain (both `project`) are kept + // distinguishable by source_curator (the harvest namespace). + let mut redmine = ModelGraph::new("redmine"); + redmine.models.push(Model::new("Issue")); + let r = &lift_model_graph(&redmine)[0]; + assert_eq!(r.source_domain.as_deref(), Some("project")); + assert_eq!(r.source_curator.as_deref(), Some("redmine")); + + let mut op = ModelGraph::new("openproject"); + op.models.push(Model::new("WorkPackage")); + let o = &lift_model_graph(&op)[0]; + assert_eq!(o.source_domain.as_deref(), Some("project")); + assert_eq!(o.source_curator.as_deref(), Some("openproject")); + + // Same domain, distinct curators — AND same canonical concept/id: + // the convergence holds while provenance stays separable. + assert_eq!(r.source_domain, o.source_domain); + assert_ne!(r.source_curator, o.source_curator); + assert_eq!(r.canonical_id(), o.canonical_id()); + + // Empty namespace → no curator tag (not an empty string). + let mut bare = ModelGraph::new(""); + bare.models.push(Model::new("X")); + assert_eq!(lift_model_graph(&bare)[0].source_curator, None); + } + #[test] fn project_work_item_role_maps_rails_dialect_synonyms() { // Universal names common to Redmine + OP. diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index fef2d0a..ad08aa1 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -191,6 +191,15 @@ pub struct Class { /// unrecognized. #[cfg_attr(feature = "serde", serde(default))] pub source_domain: Option, + /// Source **curator** — the *specific* product this class was + /// harvested from (`"openproject"`, `"redmine"`, `"odoo"`, + /// `"osb"`, …), as opposed to the coarse [`source_domain`](Self::source_domain) + /// (`"project"` / `"erp"`). Two curators in the same domain (Redmine + /// and OpenProject are both `project`) are distinguished here. Set by + /// the frontend from the harvest namespace (`ModelGraph::namespace`); + /// `None` when the frontend didn't tag one. + #[cfg_attr(feature = "serde", serde(default))] + pub source_curator: Option, /// The class's canonical **concept** — its normalized identity /// ([`canonical_concept`]); the key cross-domain convergence bridges /// on. Most names normalize lexically (`User` → `user`); proven