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
39 changes: 39 additions & 0 deletions crates/ogar-from-rails/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,45 @@ mod tests {
}
}

/// **Structural-completion trio convergence** on real source —
/// MemberRole (RBAC join), CustomValue (custom-field value), and
/// EnabledModule (project module enablement). All three are identical
/// across both curators.
#[test]
#[ignore = "requires Redmine + OpenProject checkouts"]
fn redmine_and_openproject_memberrole_customvalue_module_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 table = [
("MemberRole", "project_member_role"),
("CustomValue", "project_custom_value"),
("EnabledModule", "project_enabled_module"),
];
for (curator, classes) in [("Redmine", &redmine), ("OpenProject", &openproject)] {
for (class_name, concept) in table {
let c = classes
.iter()
.find(|c| c.name == class_name)
.unwrap_or_else(|| panic!("{curator} ships a {class_name} model"));
assert_eq!(c.canonical_concept.as_deref(), Some(concept), "{curator} {class_name}");
assert_eq!(c.source_domain.as_deref(), Some("project"));
assert_eq!(c.canonical_id(), ogar_vocab::canonical_concept_id(concept));
}
}
}

/// 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
94 changes: 94 additions & 0 deletions crates/ogar-vocab/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,9 @@ const CODEBOOK: &[(&str, u16)] = &[
("project_message", 0x0115), // Redmine Message ↔ OP Message (board/forum divergence)
("project_forum", 0x0116), // Redmine Board ↔ OP Forum (name divergence; parent of project_message)
("project_role", 0x0117), // Redmine Role ↔ OP Role (RBAC permission set)
("project_member_role", 0x0118), // MemberRole — RBAC join (membership ↔ role)
("project_custom_value", 0x0119), // CustomValue — value of a custom field on a record
("project_enabled_module", 0x011A),// EnabledModule — per-project module enablement

// ── 0x02XX — commerce / billing / ERP domain (OSB ↔ Odoo) ──
// Promoted from the parallel session's `lance-graph-ontology::ar_shape`
Expand Down Expand Up @@ -1670,6 +1673,31 @@ pub fn canonical_concept(name: &str) -> String {
) {
return "project_role".to_string();
}
// ProjectMemberRole — the RBAC join (membership ↔ role). Both
// curators ship `MemberRole` (belongs_to :member + :role).
if matches!(lower.as_str(),
"memberrole" | "member_role" | "memberroles" | "member_roles"
| "project_member_role" | "projectmemberrole"
) {
return "project_member_role".to_string();
}
// ProjectCustomValue — the value of a [`project_custom_field`] on a
// record. Both curators ship `CustomValue` (belongs_to :custom_field
// + polymorphic :customized).
if matches!(lower.as_str(),
"customvalue" | "custom_value" | "customvalues" | "custom_values"
| "project_custom_value" | "projectcustomvalue"
) {
return "project_custom_value".to_string();
}
// ProjectEnabledModule — per-project module enablement. Both curators
// ship `EnabledModule` (belongs_to :project + a name).
if matches!(lower.as_str(),
"enabledmodule" | "enabled_module" | "enabledmodules" | "enabled_modules"
| "project_enabled_module" | "projectenabledmodule"
) {
return "project_enabled_module".to_string();
}
// ── Commerce / billing / ERP domain (OSB ↔ Odoo) ──
// CommercialLineItem — line on a commercial document. OSB
// `InvoiceLineItem`, Odoo `account_move_line` (ruff normalises
Expand Down Expand Up @@ -2394,6 +2422,65 @@ pub fn project_role() -> Class {
c
}

/// `MemberRole` — the RBAC join completing the membership↔role triangle:
/// a [`project_membership`] holds one or more [`project_role`]s through
/// this join. Both curators ship `MemberRole` (`belongs_to :member` +
/// `belongs_to :role`).
#[must_use]
pub fn project_member_role() -> Class {
let mut c = Class::new("ProjectMemberRole");
c.language = Language::Unknown;
c.canonical_concept = Some("project_member_role".to_string());
c.associations = vec![
family_edge("membership", "ProjectMembership"),
family_edge("role", "ProjectRole"),
];
// Both curators track group-inherited role assignments: when a Group
// membership confers a role on its member users, the derived
// MemberRole records the parent it was inherited from.
let mut inherited_from = Attribute::new("inherited_from");
inherited_from.type_name = Some("integer".to_string());
c.attributes = vec![inherited_from];
c
}

/// `CustomValue` — the stored value of a [`project_custom_field`] on a
/// record. Both curators ship `CustomValue` with `belongs_to
/// :custom_field` + a polymorphic `:customized` (the record the value is
/// attached to — kept opaque at the canonical layer).
#[must_use]
pub fn project_custom_value() -> Class {
let mut c = Class::new("ProjectCustomValue");
c.language = Language::Unknown;
c.canonical_concept = Some("project_custom_value".to_string());
c.associations = vec![
family_edge("custom_field", "ProjectCustomField"),
];
let mut value = Attribute::new("value");
value.type_name = Some("text".to_string());
let mut customized_type = Attribute::new("customized_type");
customized_type.type_name = Some("string".to_string());
c.attributes = vec![value, customized_type];
c
}

/// `EnabledModule` — per-project module enablement (which feature modules
/// a [`project`] has turned on). Both curators ship `EnabledModule` with
/// `belongs_to :project` + a `name` (module key), unique per project.
#[must_use]
pub fn project_enabled_module() -> Class {
let mut c = Class::new("ProjectEnabledModule");
c.language = Language::Unknown;
c.canonical_concept = Some("project_enabled_module".to_string());
c.associations = vec![
family_edge("project", "Project"),
];
let mut name = Attribute::new("name");
name.type_name = Some("string".to_string());
c.attributes = vec![name];
c
}

// ─────────────────────────────────────────────────────────────────────
// Commerce / billing / ERP domain canonical classes (OSB ↔ Odoo).
//
Expand Down Expand Up @@ -3025,6 +3112,7 @@ mod tests {
"project_relation", "project_changeset", "project_watcher",
"project_news", "project_message", "project_forum",
"project_role",
"project_member_role", "project_custom_value", "project_enabled_module",
] {
let id = canonical_concept_id(project_concept)
.unwrap_or_else(|| panic!("{project_concept} missing from codebook"));
Expand Down Expand Up @@ -3077,6 +3165,9 @@ mod tests {
(project_message(), "ProjectMessage", 0x0115),
(project_forum(), "ProjectForum", 0x0116),
(project_role(), "ProjectRole", 0x0117),
(project_member_role(), "ProjectMemberRole", 0x0118),
(project_custom_value(), "ProjectCustomValue", 0x0119),
(project_enabled_module(), "ProjectEnabledModule", 0x011A),
] {
assert_eq!(canonical.name, name);
assert_eq!(canonical.language, Language::Unknown);
Expand Down Expand Up @@ -3160,6 +3251,9 @@ mod tests {
// Cross-curator name divergence: Redmine `Board` ↔ OP `Forum`.
("project_forum", &["Board", "boards", "Forum", "forums", "ProjectForum"]),
("project_role", &["Role", "roles", "ProjectRole"]),
("project_member_role", &["MemberRole", "member_roles", "ProjectMemberRole"]),
("project_custom_value", &["CustomValue", "custom_values", "ProjectCustomValue"]),
("project_enabled_module", &["EnabledModule", "enabled_modules", "ProjectEnabledModule"]),
];
for (concept, aliases) in cases {
let id = canonical_concept_id(concept).unwrap();
Expand Down
Loading