Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions crates/ogar-from-rails/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,53 @@ mod tests {
}
}

/// **Role convergence + Group→actor collapse** on real source.
/// `Role` (both curators) → `project_role`. `Group` (a Principal STI
/// subtype in both) folds into `project_actor` alongside User /
/// Principal — it is assignable / member-able exactly where a User is.
#[test]
#[ignore = "requires Redmine + OpenProject checkouts"]
fn redmine_and_openproject_role_and_group_converge() {
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(&PathBuf::from(redmine_src));
let openproject = extract(&op_path);
let role_id = ogar_vocab::canonical_concept_id("project_role");
let actor_id = ogar_vocab::canonical_concept_id("project_actor");
assert!(role_id.is_some() && actor_id.is_some());

for (curator, classes) in [("Redmine", &redmine), ("OpenProject", &openproject)] {
// Role -> project_role.
let role = classes
.iter()
.find(|c| c.name == "Role")
.unwrap_or_else(|| panic!("{curator} ships a Role model"));
assert_eq!(role.canonical_concept.as_deref(), Some("project_role"));
assert_eq!(role.canonical_id(), role_id);
// Group -> project_actor (Principal STI subtype collapse).
let group = classes
.iter()
.find(|c| c.name == "Group")
.unwrap_or_else(|| panic!("{curator} ships a Group model"));
assert_eq!(
group.canonical_concept.as_deref(),
Some("project_actor"),
"{curator} Group folds into project_actor",
);
assert_eq!(group.canonical_id(), actor_id);
}
}

/// Exactly-one-Model invariant post AdaWorldAPI/ruff#26: OP's
/// `app/models/work_package/` sub-files reopen `class WorkPackage`
/// without adding ontology declarations; before the fix this produced
Expand Down
39 changes: 39 additions & 0 deletions crates/ogar-from-ruff/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,22 @@ use ruff_spo_triplet::{
#[must_use]
pub fn lift_model_graph(graph: &ModelGraph) -> Vec<Class> {
let domain = classify_domain(&graph.namespace);
let concept_domain = domain.as_deref().and_then(ogar_vocab::source_domain_concept);
graph
.models
.iter()
.map(|m| {
let mut class = lift_model(m);
class.source_domain = domain.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
// promotion whose codebook domain doesn't match — a generic
// `Role` in a non-project curator must stay `role`, not become
// `project_role` (codex P2 on #72). Cross-domain bridges like
// `billable_work_entry` are exempt and still converge.
class.canonical_concept =
Some(ogar_vocab::canonical_concept_in_domain(&m.name, concept_domain));
class
})
.collect()
Expand Down Expand Up @@ -869,6 +879,35 @@ mod tests {
assert_eq!(lift_model_graph(&other)[0].source_domain, None);
}

#[test]
fn lift_model_graph_domain_gates_the_canonical_concept() {
// codex P2 on #72: `lift_model` is domain-blind, but
// `lift_model_graph` knows the curator's domain and must gate
// promotions through it. A bare `Role` only becomes `project_role`
// for a project-mgmt curator.
let concept = |ns: &str, model: &str| {
let mut g = ModelGraph::new(ns);
g.models.push(Model::new(model));
lift_model_graph(&g)[0].canonical_concept.clone().unwrap()
};
// Project curator (OpenProject / Redmine) — `Role` promotes.
assert_eq!(concept("openproject", "Role"), "project_role");
assert_eq!(concept("redmine", "Role"), "project_role");
// Unrelated curator (domain None) — `Role` stays lexical, NOT
// project_role, so it can't route as ConceptDomain::ProjectMgmt.
assert_eq!(concept("mystery", "Role"), "role");
// Foreign-but-known domain (erp) — also withheld.
assert_eq!(concept("odoo", "Role"), "role");
// Cross-domain bridge survives the gate from any domain.
assert_eq!(concept("openproject", "TimeEntry"), "billable_work_entry");
assert_eq!(concept("odoo", "account_analytic_line"), "billable_work_entry");
// `lift_model` itself stays domain-blind (all-domains best guess).
assert_eq!(
lift_model(&Model::new("Role")).canonical_concept.as_deref(),
Some("project_role"),
);
}

#[test]
fn project_work_item_role_maps_rails_dialect_synonyms() {
// Universal names common to Redmine + OP.
Expand Down
Loading
Loading