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
174 changes: 174 additions & 0 deletions crates/ogar-from-rails/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,4 +111,178 @@ mod tests {
// reachable by accessing it.
let _ = any_parent_set;
}

/// Real-corpus **convergence proof** against the Redmine source tree
/// (`AdaWorldAPI/redmine` — OpenProject's project-domain ancestor).
/// Set `REDMINE_SRC` to the checkout root. Redmine ships a real
/// `TimeEntry` model, so this proves the BillableWorkEntry convergence
/// on actual data: a project-domain `TimeEntry` materializes to the
/// same canonical concept (`billable_work_entry`) that Odoo's
/// `account.analytic.line` does.
#[test]
#[ignore = "requires a Redmine checkout via REDMINE_SRC"]
fn redmine_timeentry_converges_to_billable_work_entry() {
let Ok(src) = std::env::var("REDMINE_SRC") else {
eprintln!("skipping: REDMINE_SRC not set");
return;
};
let classes = extract(&PathBuf::from(src));
assert!(!classes.is_empty(), "expected Redmine models, got none");
let time_entry = classes
.iter()
.find(|c| c.name == "TimeEntry")
.expect("Redmine ships a TimeEntry model");
assert_eq!(
time_entry.canonical_concept.as_deref(),
Some("billable_work_entry"),
"Redmine TimeEntry must converge to the BillableWorkEntry concept",
);
assert_eq!(
time_entry.source_domain.as_deref(),
Some("project"),
"Rails frontend tags the project domain",
);
}

/// Real-corpus **same-domain convergence proof** across the fork
/// lineage Redmine → ChiliProject → OpenProject. Both Redmine `Issue`
/// and OpenProject `WorkPackage` must lift to the *same* canonical
/// concept (`project_work_item`) — and OpenProject's later modular
/// enrichment (extra includes) must not change that.
///
/// Set `REDMINE_SRC` and (optionally) `OPENPROJECT_SRC` (defaults to
/// `/home/user/openproject`). Skips gracefully if either is missing.
#[test]
#[ignore = "requires Redmine + OpenProject checkouts"]
fn redmine_issue_and_openproject_work_package_overlap_as_project_work_item() {
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 issue = redmine
.iter()
.find(|c| c.name == "Issue")
.expect("Redmine ships an Issue model");
let work_package = openproject
.iter()
.find(|c| c.name == "WorkPackage")
.expect("OpenProject ships a WorkPackage model");

// The headline: both materialize to the SAME canonical concept,
// detected deterministically from class names alone. This is the
// load-bearing convergence assertion — it holds *regardless* of
// per-curator surface-extraction depth.
assert_eq!(issue.canonical_concept.as_deref(), Some("project_work_item"));
assert_eq!(
work_package.canonical_concept.as_deref(),
Some("project_work_item"),
);
// Same domain — both are project-domain curators.
assert_eq!(issue.source_domain.as_deref(), Some("project"));
assert_eq!(work_package.source_domain.as_deref(), Some("project"));

// Strict structural overlap: both curators extract the canonical
// roles. Redmine `Issue` (the cleaner AR fossil) and OP
// `WorkPackage` (the richer organism — reopens merged by
// AdaWorldAPI/ruff#26) both carry `project`, `author`,
// `time_entries` after extraction. The role *names* are leaf
// details that may diverge (`assigned_to` vs `assignee`, `tracker`
// vs `type`); the canonical_concept is what unifies them.
for c in [issue, work_package] {
for role in ["project", "author", "time_entries"] {
assert!(
c.associations.iter().any(|a| a.name == role),
"{} must carry a `{}` association",
c.name,
role,
);
}
}
}

/// Enrichment must not break the overlap. OpenProject `WorkPackage`
/// is the strictly richer organism — extra modular includes
/// (`WorkPackages::SpentTime` / `Costs` / `Relations`,
/// `OpenProject::Journal::AttachmentHelper`, …) on top of the cleaner
/// Redmine `Issue` AR shape — yet the canonical concept is invariant.
/// Post AdaWorldAPI/ruff#26 (reopen-merge), OP extracts its full
/// body, so the richer-mixin assertion is enforceable.
#[test]
#[ignore = "requires Redmine + OpenProject checkouts"]
fn openproject_enrichment_does_not_break_redmine_ar_overlap() {
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 issue = redmine.iter().find(|c| c.name == "Issue").unwrap();
let work_package = openproject
.iter()
.find(|c| c.name == "WorkPackage")
.unwrap();

// OP is strictly the richer organism at the mixin axis ...
assert!(
work_package.mixins.len() > issue.mixins.len(),
"OP WorkPackage should be strictly richer than Redmine Issue \
at the mixin axis (OP: {} mixins, Redmine: {} mixins)",
work_package.mixins.len(),
issue.mixins.len(),
);
// ... yet the canonical concept is invariant: enrichment did not
// break the overlap. This is the load-bearing agnostic-vocab
// claim made concrete on real source.
assert_eq!(issue.canonical_concept, work_package.canonical_concept);
assert_eq!(
issue.canonical_concept.as_deref(),
Some("project_work_item"),
);
}

/// 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
/// duplicate `Model { name: "WorkPackage" }` entries (one empty, one
/// rich) and `.find()` could land on either. After the fix, the
/// producer merges them into one — pin that behavior so consumers can
/// trust uniqueness-by-name.
#[test]
#[ignore = "requires an OpenProject checkout"]
fn openproject_workpackage_extracts_as_exactly_one_class() {
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 classes = extract(&op_path);
let n = classes.iter().filter(|c| c.name == "WorkPackage").count();
assert_eq!(
n, 1,
"OpenProject `WorkPackage` must extract as exactly one Class \
(reopen-merge from AdaWorldAPI/ruff#26); got {n}",
);
}
}
34 changes: 32 additions & 2 deletions crates/ogar-from-ruff/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@
#![warn(missing_docs)]

use ogar_vocab::{
ActionDef, Association, AssociationKind, Attribute, Callback, Class, EnumDecl, EnumSource,
Inheritance, Language, Scope, Validation,
canonical_concept, ActionDef, Association, AssociationKind, Attribute, Callback, Class,
EnumDecl, EnumSource, Inheritance, Language, Scope, Validation,
};
use ruff_spo_triplet::{
AssocDecl, AssocKind, AttrDecl, AttrKind, Callback as RuffCallback, ConcernKind, Model,
Expand Down Expand Up @@ -101,6 +101,9 @@ fn classify_domain(namespace: &str) -> Option<String> {
Some("project".to_string())
} else if ns.contains("odoo") {
Some("erp".to_string())
} else if ns.contains("woa") || ns.contains("smb") {
// WoA-rs / SMB — the German-ERP sanity witness adapter.
Some("german-erp".to_string())
} else {
None
}
Expand All @@ -120,6 +123,7 @@ pub fn lift_model(model: &Model) -> Class {
class.language = Language::Ruby;
class.parent = model.sti.as_ref().and_then(sti_parent);
class.inheritance = lift_inheritance(model);
class.canonical_concept = Some(canonical_concept(&model.name));
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();
Expand Down Expand Up @@ -748,6 +752,32 @@ mod tests {
assert_eq!(lift_model_graph(&other)[0].source_domain, None);
}

#[test]
fn lift_model_sets_canonical_concept_including_promoted_invariant() {
// Plain class with no promoted invariant → lexical concept.
assert_eq!(
lift_model(&Model::new("Account")).canonical_concept.as_deref(),
Some("account"),
);
// Promoted ERP-bridge concept (BillableWorkEntry) — OpenProject
// `TimeEntry` deterministically wired into the cross-domain bridge.
assert_eq!(
lift_model(&Model::new("TimeEntry")).canonical_concept.as_deref(),
Some("billable_work_entry"),
);
// Promoted project-domain concept (ProjectWorkItem) — Redmine
// `Issue` and OpenProject `WorkPackage` both wire into the
// same-domain work-item invariant.
assert_eq!(
lift_model(&Model::new("Issue")).canonical_concept.as_deref(),
Some("project_work_item"),
);
assert_eq!(
lift_model(&Model::new("WorkPackage")).canonical_concept.as_deref(),
Some("project_work_item"),
);
}

fn mk_model_with_functions() -> Model {
let mut m = Model::new("WorkPackage");
m.functions.push(Function {
Expand Down
Loading
Loading