From ec3322554af8f8f8674e9a879eba6f0359fe67ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:05:00 +0000 Subject: [PATCH 1/5] =?UTF-8?q?feat(ogar-vocab):=20BillableWorkEntry=20?= =?UTF-8?q?=E2=80=94=20first=20convergence=20class=20(12=20family=20edges)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first real cross-domain convergence, as code (not a synergy taxonomy). OpenProject TimeEntry and Odoo account.analytic.line don't match lexically but ARE the same invariant: booked work/time/cost against project-scoped activity, potentially billable, later materializable into an invoice line. - Class.canonical_concept: Option — the deterministic bridge key. Any consumer session rediscovers the same convergence from ontology surfaces alone (domain tag + name), no memory/vibes. - canonical_concept() resolver: promoted-invariant layer maps OP TimeEntry, Odoo account.analytic.line (dotted + ruff-underscored), and WoA Leistungsposition/Arbeitszeit -> 'billable_work_entry'; lexical fallback otherwise. Pure + deterministic. - billable_work_entry() canonical class: 12 INTERNAL family edges (Project, WorkPackage, Worker, Duration, RatePolicy, CostCenter, TaxPolicy, InvoiceLineCandidate, ApprovalState, Tenant, AuditTrail, PostingAction). Adapter edges stay OUT of family — on the curator class via source_domain + canonical_concept, never among the 12. - Tax is a boundary policy: classified_by/materializes_as/posted_by are ERP-boundary edges; project domain records work evidence, never tax. - materializes_as is BelongsTo: one InvoiceLineCandidate aggregates many BillableWorkEntries (one-to-many). - classify_domain adds german-erp (WoA-rs witness) — the 3rd curator adapter. - ogar-from-ruff wires canonical_concept at lift time. No SynergyKind/SynergyFinding/reporting structs. Additive; deterministic. ogar-vocab 20, ogar-from-ruff 17 green (incl. the 5 convergence tests); all 5 other consumers build; clippy clean. --- crates/ogar-from-ruff/src/lib.rs | 23 ++- crates/ogar-vocab/src/lib.rs | 247 ++++++++++++++++++++++++++++++- 2 files changed, 261 insertions(+), 9 deletions(-) diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 552dd0c..d381266 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -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, @@ -101,6 +101,9 @@ fn classify_domain(namespace: &str) -> Option { 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 } @@ -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(); @@ -748,6 +752,21 @@ mod tests { assert_eq!(lift_model_graph(&other)[0].source_domain, None); } + #[test] + fn lift_model_sets_canonical_concept_including_promoted_invariant() { + // Plain class → lexical concept. + assert_eq!( + lift_model(&Model::new("WorkPackage")).canonical_concept.as_deref(), + Some("workpackage"), + ); + // Promoted invariant → the BillableWorkEntry canonical concept: + // the OpenProject curator wired deterministically into the bridge. + assert_eq!( + lift_model(&Model::new("TimeEntry")).canonical_concept.as_deref(), + Some("billable_work_entry"), + ); + } + 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 025ebb7..f050737 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, + /// The class's canonical **concept** — its normalized identity + /// ([`canonical_concept`]); the key cross-domain convergence bridges + /// on. Most names normalize lexically (`User` → `user`); proven + /// cross-domain invariants resolve to a promoted concept (OpenProject + /// `TimeEntry` and Odoo `account.analytic.line` both → + /// `billable_work_entry`, the [`billable_work_entry`] canonical class). + /// Set by the frontend at lift time. + #[cfg_attr(feature = "serde", serde(default))] + pub canonical_concept: 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. @@ -1120,7 +1129,13 @@ pub fn wire_synergies(classes: &[Class]) -> Vec { let Some(domain) = c.source_domain.as_ref() else { continue; }; - let concept = canonical_concept(&c.name); + // Prefer the concept the producer stored; else compute it + // deterministically from the name — so any consumer session + // rediscovers the same bridge from ontology surfaces alone. + let concept = c + .canonical_concept + .clone() + .unwrap_or_else(|| canonical_concept(&c.name)); by_concept .entry(concept) .or_default() @@ -1140,13 +1155,38 @@ pub fn wire_synergies(classes: &[Class]) -> Vec { .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 { +/// Resolve a class name to its canonical OGAR **concept**. +/// +/// Two layers, in order: +/// 1. **Promoted cross-domain invariants** — concepts a Claude Code +/// convergence pass has proven across 2+ domains and promoted into +/// OGAR. OGAR stores only the stable result; the proof is the test, the +/// "finding" was the PR that added the arm. (No `SynergyKind` / +/// `SynergyFinding` taxonomy — convergence is an operation, not a +/// stored object.) +/// 2. **Lexical fallback** — lowercase, last dotted segment (Odoo +/// `res.users` → `users`), drop a single trailing plural `s` except +/// after `ss`. Coarse by design, not a thesaurus. +#[must_use] +pub fn canonical_concept(name: &str) -> String { let lower = name.to_ascii_lowercase(); + // ── Layer 1: promoted invariants ── + // BillableWorkEntry — booked work / time / cost against a project or + // order (see [`billable_work_entry`]). OpenProject `TimeEntry` ↔ Odoo + // `account.analytic.line` ↔ WoA `Arbeitszeit`. ruff normalizes Odoo + // dots to underscores, so match both forms. + if matches!( + lower.as_str(), + "timeentry" + | "time_entry" + | "account.analytic.line" + | "account_analytic_line" + | "leistungsposition" + | "arbeitszeit" + ) { + return "billable_work_entry".to_string(); + } + // ── Layer 2: lexical fallback ── 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() @@ -1155,6 +1195,66 @@ fn canonical_concept(name: &str) -> String { } } +/// The promoted canonical class for the **first convergence invariant**: +/// booked work / time / cost against a project or order. The shared shape +/// under OpenProject `TimeEntry` (project domain), Odoo +/// `account.analytic.line` (erp domain), and WoA `Leistungsposition` / +/// `Arbeitszeit` (german-erp witness). Curators map in via +/// [`canonical_concept`] (`"billable_work_entry"`). +/// +/// This is OGAR storing the *stable result* of a convergence pass — not a +/// synergy taxonomy. +/// +/// # The 12 family edges (internal) + the adapter edge (external) +/// +/// BillableWorkEntry carries **12 family edges** — relations to other +/// canonical concepts, internal to the ontology. The link from a curator +/// surface (OpenProject `TimeEntry`, Odoo `account.analytic.line`) is the +/// **adapter edge**, living *out of family* on the curator class (its +/// `source_domain` + `canonical_concept`), never among these edges. +/// +/// **Tax is a boundary policy.** Three family edges — `classified_by → +/// TaxPolicy`, `materializes_as → InvoiceLineCandidate`, `posted_by → +/// PostingAction` — are populated only past the **ERP / posting +/// boundary**. The project domain records work evidence (`duration`, +/// `about → WorkPackage`, `performed_by → Worker`) and never applies tax. +/// +/// `materializes_as` is `BelongsTo`, so many BillableWorkEntries aggregate +/// into one `InvoiceLineCandidate` (one invoice line, many work entries). +#[must_use] +pub fn billable_work_entry() -> Class { + let mut c = Class::new("BillableWorkEntry"); + c.canonical_concept = Some("billable_work_entry".to_string()); + // The 12 family edges — internal ontology meaning. Every target is a + // canonical concept (PascalCase), never a curator/adapter surface. + c.associations = vec![ + family_edge("project", "Project"), + family_edge("about", "WorkPackage"), + family_edge("performed_by", "Worker"), + family_edge("duration", "Duration"), + family_edge("priced_by", "RatePolicy"), + family_edge("cost_center", "CostCenter"), + family_edge("classified_by", "TaxPolicy"), // ERP boundary + family_edge("materializes_as", "InvoiceLineCandidate"), // ERP boundary + family_edge("approval_state", "ApprovalState"), + family_edge("tenant", "Tenant"), + family_edge("audit_trail", "AuditTrail"), + family_edge("posted_by", "PostingAction"), // ERP boundary + ]; + // The defining flag (a scalar, not an edge). + c.attributes = vec![Attribute::new("billable")]; + c +} + +/// Build one BillableWorkEntry **family edge** — a `BelongsTo` relation to +/// a canonical ontology concept (the edge's `class_name`). Family edges +/// are internal; curator / adapter links never appear here. +fn family_edge(role: &str, target_concept: &str) -> Association { + let mut a = Association::new(AssociationKind::BelongsTo, role); + a.class_name = Some(target_concept.to_string()); + a +} + #[cfg(test)] mod tests { use super::*; @@ -1207,6 +1307,139 @@ mod tests { assert!(wire_synergies(&[a, b, c]).is_empty()); } + #[test] + fn canonical_concept_promotes_billable_work_entry_deterministically() { + // Promoted cross-domain invariant — OpenProject `TimeEntry` and + // Odoo `account.analytic.line` converge to one canonical concept + // (both the dotted and ruff's underscored form). Pure + + // deterministic: same input → same output, every session. + for name in [ + "TimeEntry", + "time_entry", + "account.analytic.line", + "account_analytic_line", + "Leistungsposition", + "Arbeitszeit", + ] { + assert_eq!(canonical_concept(name), "billable_work_entry"); + assert_eq!(canonical_concept(name), canonical_concept(name)); + } + // Un-promoted names still normalize lexically. + assert_eq!(canonical_concept("User"), "user"); + assert_eq!(canonical_concept("res.users"), "user"); + } + + #[test] + fn billable_work_entry_has_twelve_family_edges() { + let c = billable_work_entry(); + assert_eq!(c.name, "BillableWorkEntry"); + assert_eq!(c.canonical_concept.as_deref(), Some("billable_work_entry")); + // Exactly the 12 internal family edges, to canonical concepts. + assert_eq!(c.associations.len(), 12); + for target in [ + "Project", + "WorkPackage", + "Worker", + "Duration", + "RatePolicy", + "CostCenter", + "TaxPolicy", + "InvoiceLineCandidate", + "ApprovalState", + "Tenant", + "AuditTrail", + "PostingAction", + ] { + assert!( + c.associations.iter().any(|e| e.class_name.as_deref() == Some(target)), + "missing family edge → {target}", + ); + } + } + + #[test] + fn convergence_project_and_erp_materialize_to_billable_work_entry() { + let canonical = billable_work_entry(); + // Two curators, only domain tag + name — a consumer session + // rediscovers the bridge deterministically from these surfaces. + let mut op = Class::new("TimeEntry"); + op.source_domain = Some("project".to_string()); + op.canonical_concept = Some(canonical_concept("TimeEntry")); + let mut odoo = Class::new("account_analytic_line"); + odoo.source_domain = Some("erp".to_string()); + odoo.canonical_concept = Some(canonical_concept("account_analytic_line")); + + // Both materialize to the SAME canonical concept as the class. + assert_eq!(op.canonical_concept, canonical.canonical_concept); + assert_eq!(odoo.canonical_concept, canonical.canonical_concept); + + // wire_synergies rediscovers exactly one cross-domain bridge, + // and is idempotent (deterministic). + let syn = wire_synergies(&[op.clone(), odoo.clone()]); + assert_eq!(syn, wire_synergies(&[op, odoo])); + assert_eq!(syn.len(), 1); + assert_eq!(syn[0].concept, "billable_work_entry"); + assert_eq!(syn[0].members.len(), 2); + } + + #[test] + fn tax_policy_is_an_erp_boundary_edge_not_in_project_evidence() { + // TaxPolicy is a family edge on the canonical shape ... + let bwe = billable_work_entry(); + assert!(bwe + .associations + .iter() + .any(|e| e.class_name.as_deref() == Some("TaxPolicy"))); + // ... but the project curator records work evidence with no tax. + let mut op = Class::new("TimeEntry"); + op.source_domain = Some("project".to_string()); + op.canonical_concept = Some(canonical_concept("TimeEntry")); + assert!(op.associations.is_empty()); + assert!(!op.attributes.iter().any(|a| a.name.contains("tax"))); + } + + #[test] + fn one_invoice_line_aggregates_many_billable_work_entries() { + let bwe = billable_work_entry(); + let mat = bwe + .associations + .iter() + .find(|e| e.name == "materializes_as") + .expect("materializes_as edge"); + // BelongsTo: many BillableWorkEntries → one InvoiceLineCandidate + // (one invoice line aggregates many work entries). + assert_eq!(mat.kind, AssociationKind::BelongsTo); + assert_eq!(mat.class_name.as_deref(), Some("InvoiceLineCandidate")); + } + + #[test] + fn family_edges_internal_adapter_edges_external() { + let bwe = billable_work_entry(); + // All 12 family-edge targets are ONTOLOGY concepts (PascalCase), + // never curator / adapter surfaces — internal by construction. + assert_eq!(bwe.associations.len(), 12); + for e in &bwe.associations { + let target = e.class_name.as_deref().unwrap_or_default(); + assert!( + target.starts_with(|ch: char| ch.is_ascii_uppercase()), + "family edge target must be an ontology concept: {target:?}", + ); + for curator in ["TimeEntry", "account.", "account_", "OpenProject", "Odoo", "res."] { + assert!( + !target.contains(curator), + "curator surface leaked into a family edge: {target:?}", + ); + } + } + // The adapter edge lives OUT of family — on the curator class + // (source_domain + canonical_concept), not among these edges. + let mut op = Class::new("TimeEntry"); + op.source_domain = Some("project".to_string()); + op.canonical_concept = Some(canonical_concept("TimeEntry")); + assert_eq!(op.canonical_concept.as_deref(), Some("billable_work_entry")); + assert!(bwe.associations.iter().all(|e| e.name != "TimeEntry")); + } + #[test] fn elixir_language_is_a_distinct_first_class_variant() { // The OLD HIRO/Bardioc stack is Elixir; it is a first-class source From d056c955649830e7cef6a877a19ea8966b7d9755 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:07:50 +0000 Subject: [PATCH 2/5] test(ogar-from-rails): real-corpus convergence proof on Redmine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an (ignored, REDMINE_SRC-driven) smoke test proving the BillableWorkEntry convergence on the actual AdaWorldAPI/redmine source — OpenProject's project-domain ancestor (Redmine -> ChiliProject -> OpenProject), the cleaner AR fossil. Verified locally against an 82-model Redmine checkout: its real TimeEntry model lifts to canonical_concept 'billable_work_entry' and source_domain 'project' — the same canonical concept Odoo account.analytic.line maps to. Convergence proven on real data, not fixtures. --- crates/ogar-from-rails/src/lib.rs | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/ogar-from-rails/src/lib.rs b/crates/ogar-from-rails/src/lib.rs index d42260e..a469d40 100644 --- a/crates/ogar-from-rails/src/lib.rs +++ b/crates/ogar-from-rails/src/lib.rs @@ -111,4 +111,36 @@ 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", + ); + } } From a7833302b37db50a87953303129df85513df43f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:29:11 +0000 Subject: [PATCH 3/5] =?UTF-8?q?feat(ogar-vocab):=20ProjectWorkItem=20?= =?UTF-8?q?=E2=80=94=20same-domain=20convergence=20across=20the=20Redmine?= =?UTF-8?q?=20->=20ChiliProject=20->=20OpenProject=20fork=20lineage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second convergence class, layered on BillableWorkEntry. Different angle: SAME-domain (project) convergence across fork lineage, not cross-domain. Redmine Issue (the cleaner AR fossil) and OpenProject WorkPackage (the richer modular organism) both lift to the same canonical concept, deterministically, from class names alone. - canonical_concept(): adds the 'project_work_item' arm — matches Issue, WorkPackage, workpackage, work_package. Pure + deterministic; any consumer session rediscovers the bridge from ontology surfaces alone. - project_work_item() canonical class with 9 family edges, all internal to the project domain (no ERP-boundary slots): BelongsTo: project, status, type, priority, author, assignee HasMany: journals, relations, time_entries (-> BillableWorkEntry) The time_entries edge wires the project lattice into the BillableWork- Entry ERP bridge — additive growth, the next concept connects naturally. - family_has_many() helper (additive; family_edge() unchanged). - 4 new unit tests: canonical_concept_promotes_project_work_item_deterministically project_work_item_has_required_family_edges same_project_domain_curators_do_not_create_duplicate_canonical_concepts openproject_enrichment_does_not_break_redmine_ar_overlap - 2 new real-corpus tests (REDMINE_SRC + OPENPROJECT_SRC env): redmine_issue_and_openproject_work_package_overlap_as_project_work_item openproject_enrichment_does_not_break_redmine_ar_overlap Both verified locally against /tmp/redmine + /home/user/openproject. ogar-from-ruff existing test updated to use unpromoted name (Account) and to cover both promoted invariants (TimeEntry, Issue, WorkPackage). Known: current AdaWorldAPI/ruff main (96ed65f) extracts OP WorkPackage's body as empty (likely the %w[].freeze constants or self-referential WorkPackage::Foo include chain). The convergence proof holds regardless — the canonical concept is detected from the class name alone — so the real-corpus tests assert structural overlap only where the producer actually extracted something. Fixing the ruff parser is a separate sprint. ogar-vocab 21, ogar-from-ruff 20 unit + 4 real-corpus smokes green; clippy clean; disk steady. --- crates/ogar-from-rails/src/lib.rs | 121 ++++++++++++++++++++++++++ crates/ogar-from-ruff/src/lib.rs | 21 +++-- crates/ogar-vocab/src/lib.rs | 139 ++++++++++++++++++++++++++++++ 3 files changed, 276 insertions(+), 5 deletions(-) diff --git a/crates/ogar-from-rails/src/lib.rs b/crates/ogar-from-rails/src/lib.rs index a469d40..1495811 100644 --- a/crates/ogar-from-rails/src/lib.rs +++ b/crates/ogar-from-rails/src/lib.rs @@ -143,4 +143,125 @@ mod tests { "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")); + + // Redmine Issue is the cleaner AR fossil — extraction reliably + // captures its full surface; assert the canonical roles are + // present. + assert!( + issue.associations.iter().any(|a| a.name == "project"), + "Redmine Issue must carry a `project` association", + ); + assert!( + issue.associations.iter().any(|a| a.name == "author"), + "Redmine Issue must carry an `author` association", + ); + assert!( + issue.associations.iter().any(|a| a.name == "time_entries"), + "Redmine Issue must carry a `time_entries` association", + ); + + // OpenProject WorkPackage's body uses constructs the current + // ruff_ruby_spo (96ed65f) bails on — self-referential + // `include WorkPackage::Foo` chains + top-level `%w[…].freeze` + // constants — so its extracted surface is currently sparse. A + // ruff sprint follow-up will lift those. Until then: assert the + // structural overlap only where the producer actually extracted + // something, so the convergence proof does not depend on parser + // completeness. + if !work_package.associations.is_empty() { + assert!( + work_package.associations.iter().any(|a| a.name == "project"), + "OP WorkPackage extracted associations but no `project`", + ); + } + } + + /// Enrichment must not break the overlap: any extraction-depth + /// difference between the cleaner Redmine `Issue` and the richer + /// OpenProject `WorkPackage` (extra modular includes — + /// `WorkPackages::SpentTime` / `Costs` / `Relations`) leaves the + /// canonical concept invariant. Holds both ways: when OP extracts + /// strictly more surface (the post-ruff-fix state), and when OP + /// extracts strictly less (the current ruff parser gap on + /// `WorkPackage`'s `%w[…].freeze` / self-include chain). + #[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(); + + // Headline: the canonical concept is identical regardless of + // extraction-depth difference. Enrichment did not break overlap. + assert_eq!(issue.canonical_concept, work_package.canonical_concept); + assert_eq!( + issue.canonical_concept.as_deref(), + Some("project_work_item"), + ); + } } diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index d381266..5ea5f3c 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -754,17 +754,28 @@ mod tests { #[test] fn lift_model_sets_canonical_concept_including_promoted_invariant() { - // Plain class → lexical concept. + // Plain class with no promoted invariant → lexical concept. assert_eq!( - lift_model(&Model::new("WorkPackage")).canonical_concept.as_deref(), - Some("workpackage"), + lift_model(&Model::new("Account")).canonical_concept.as_deref(), + Some("account"), ); - // Promoted invariant → the BillableWorkEntry canonical concept: - // the OpenProject curator wired deterministically into the bridge. + // 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 { diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index f050737..965a2a9 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1186,6 +1186,15 @@ pub fn canonical_concept(name: &str) -> String { ) { return "billable_work_entry".to_string(); } + // ProjectWorkItem — project-scoped work items with status, assignment, + // author, type/tracker, journals, relations, time tracking. The + // Redmine `Issue` and OpenProject `WorkPackage` overlap (the fork + // lineage Redmine → ChiliProject → OpenProject preserves the + // invariant) — both lift here regardless of OpenProject's later + // modular enrichment. See [`project_work_item`]. + if matches!(lower.as_str(), "issue" | "workpackage" | "work_package") { + return "project_work_item".to_string(); + } // ── Layer 2: lexical fallback ── let last = lower.rsplit('.').next().unwrap_or(lower.as_str()); if last.len() > 3 && last.ends_with('s') && !last.ends_with("ss") { @@ -1255,6 +1264,47 @@ fn family_edge(role: &str, target_concept: &str) -> Association { a } +/// Build one **has-many** family edge — for canonical concepts that +/// aggregate (a [`project_work_item`] has-many `ProjectJournal`s, etc.). +fn family_has_many(role: &str, target_concept: &str) -> Association { + let mut a = Association::new(AssociationKind::HasMany, role); + a.class_name = Some(target_concept.to_string()); + a +} + +/// The promoted canonical class for the **project-domain work-item +/// invariant**: project-scoped work with status, assignment, type/tracker, +/// priority, author, journals, relations, and time tracking. +/// +/// The Redmine → ChiliProject → OpenProject lineage preserves this +/// invariant: Redmine `Issue` and OpenProject `WorkPackage` both map here +/// via [`canonical_concept`] (`"project_work_item"`). Curator labels +/// (`Tracker`, `Type`, `assigned_to`, `responsible`) are leaf details on +/// the curator class; only the canonical roles survive here. +/// +/// The 9 family edges sit fully **inside the project domain** — no +/// ERP-boundary slots. The cross-domain bridge to billable work lives on +/// the `time_entries → BillableWorkEntry` has-many edge (project work +/// produces billable work; tax/posting happens past +/// [`billable_work_entry`]'s ERP-boundary edges). +#[must_use] +pub fn project_work_item() -> Class { + let mut c = Class::new("ProjectWorkItem"); + c.canonical_concept = Some("project_work_item".to_string()); + c.associations = vec![ + family_edge("project", "Project"), + family_edge("status", "ProjectStatus"), + family_edge("type", "ProjectType"), // Redmine Tracker / OP Type + family_edge("priority", "Priority"), + family_edge("author", "ProjectActor"), + family_edge("assignee", "ProjectActor"), // Redmine assigned_to / OP assignee + family_has_many("journals", "ProjectJournal"), + family_has_many("relations", "ProjectRelation"), + family_has_many("time_entries", "BillableWorkEntry"), + ]; + c +} + #[cfg(test)] mod tests { use super::*; @@ -1412,6 +1462,95 @@ mod tests { assert_eq!(mat.class_name.as_deref(), Some("InvoiceLineCandidate")); } + #[test] + fn canonical_concept_promotes_project_work_item_deterministically() { + // Promoted project-domain invariant — Redmine `Issue` and + // OpenProject `WorkPackage` (both spellings) resolve to one + // canonical concept. Pure + deterministic. + for name in ["Issue", "issue", "WorkPackage", "work_package", "workpackage"] { + assert_eq!(canonical_concept(name), "project_work_item"); + assert_eq!(canonical_concept(name), canonical_concept(name)); + } + } + + #[test] + fn project_work_item_has_required_family_edges() { + let c = project_work_item(); + assert_eq!(c.name, "ProjectWorkItem"); + assert_eq!(c.canonical_concept.as_deref(), Some("project_work_item")); + // The 9 family edges named in the smoke spec. + for (role, target) in [ + ("project", "Project"), + ("status", "ProjectStatus"), + ("type", "ProjectType"), + ("priority", "Priority"), + ("author", "ProjectActor"), + ("assignee", "ProjectActor"), + ("journals", "ProjectJournal"), + ("relations", "ProjectRelation"), + ("time_entries", "BillableWorkEntry"), + ] { + let e = c + .associations + .iter() + .find(|a| a.name == role) + .unwrap_or_else(|| panic!("missing family edge: {role}")); + assert_eq!(e.class_name.as_deref(), Some(target)); + } + // has-many vs belongs-to cardinality is correct: journals / + // relations / time_entries aggregate; the rest are single refs. + for role in ["journals", "relations", "time_entries"] { + let e = c.associations.iter().find(|a| a.name == role).unwrap(); + assert_eq!(e.kind, AssociationKind::HasMany); + } + } + + #[test] + fn same_project_domain_curators_do_not_create_duplicate_canonical_concepts() { + // Redmine `Issue` and OpenProject `WorkPackage` are project-domain + // work-item curators; they MUST converge to one canonical concept, + // never two — that's exactly what makes the agnostic vocab worth + // more than its curators. + assert_eq!(canonical_concept("Issue"), canonical_concept("WorkPackage")); + assert_eq!(canonical_concept("Issue"), "project_work_item"); + // The lexical layer remains deterministic for unpromoted names. + assert_eq!(canonical_concept("User"), canonical_concept("Users")); + } + + #[test] + fn openproject_enrichment_does_not_break_redmine_ar_overlap() { + // OpenProject's WorkPackage is the richer organism (extra includes + // like `WorkPackages::SpentTime`, `WorkPackages::Costs`, + // `WorkPackages::Relations`); Redmine's Issue is the cleaner AR + // fossil. The agnostic vocab survives the evolution: both lift to + // the same canonical concept. + let mut redmine_issue = Class::new("Issue"); + redmine_issue.source_domain = Some("project".to_string()); + redmine_issue.canonical_concept = Some(canonical_concept("Issue")); + redmine_issue.mixins = vec!["Redmine::Acts::Mentionable".to_string()]; + + let mut op_wp = Class::new("WorkPackage"); + op_wp.source_domain = Some("project".to_string()); + op_wp.canonical_concept = Some(canonical_concept("WorkPackage")); + op_wp.mixins = vec![ + "WorkPackages::SpentTime".to_string(), + "WorkPackages::Costs".to_string(), + "WorkPackages::Relations".to_string(), + "WorkPackages::Scheduling".to_string(), + "OpenProject::Journal::AttachmentHelper".to_string(), + ]; + + // OP is strictly richer than Redmine at the mixin axis ... + assert!(op_wp.mixins.len() > redmine_issue.mixins.len()); + // ... yet the canonical concept is identical: enrichment did not + // break the overlap. + assert_eq!(redmine_issue.canonical_concept, op_wp.canonical_concept); + assert_eq!( + redmine_issue.canonical_concept.as_deref(), + Some("project_work_item"), + ); + } + #[test] fn family_edges_internal_adapter_edges_external() { let bwe = billable_work_entry(); From 0104a491dc8afb08618072b625cbb058a36d6591 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:43:41 +0000 Subject: [PATCH 4/5] test(ogar-from-rails): tighten OP assertions; pin exactly-one-Model invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdaWorldAPI/ruff#26 merged: the producer now merges Ruby class-reopens into a single Model. With OP WorkPackage extracting its full body (18 associations, 23 mixins) instead of as an empty reopener-shadow, the previously-soft real-corpus assertions can become strict. - redmine_issue_and_openproject_work_package_overlap_as_project_work_item: require BOTH Issue and WorkPackage to carry the canonical roles (project, author, time_entries) — drop the OP-only sparse-extraction accommodation. Role NAMES are leaf details; canonical_concept unifies. - openproject_enrichment_does_not_break_redmine_ar_overlap: now requires OP to be STRICTLY richer at the mixin axis (was >=, now >). The canonical-concept invariance is the load-bearing claim. - New: openproject_workpackage_extracts_as_exactly_one_class — pins the uniqueness-by-name property the producer now guarantees, so consumers can trust .find(name). (Cargo.lock is gitignored; cargo will resolve the post-#26 ruff main on next build automatically.) 5 real-corpus tests green; 21 unit; clippy clean. --- crates/ogar-from-rails/src/lib.rs | 99 +++++++++++++++++++------------ 1 file changed, 60 insertions(+), 39 deletions(-) diff --git a/crates/ogar-from-rails/src/lib.rs b/crates/ogar-from-rails/src/lib.rs index 1495811..280ba0a 100644 --- a/crates/ogar-from-rails/src/lib.rs +++ b/crates/ogar-from-rails/src/lib.rs @@ -192,46 +192,32 @@ mod tests { assert_eq!(issue.source_domain.as_deref(), Some("project")); assert_eq!(work_package.source_domain.as_deref(), Some("project")); - // Redmine Issue is the cleaner AR fossil — extraction reliably - // captures its full surface; assert the canonical roles are - // present. - assert!( - issue.associations.iter().any(|a| a.name == "project"), - "Redmine Issue must carry a `project` association", - ); - assert!( - issue.associations.iter().any(|a| a.name == "author"), - "Redmine Issue must carry an `author` association", - ); - assert!( - issue.associations.iter().any(|a| a.name == "time_entries"), - "Redmine Issue must carry a `time_entries` association", - ); - - // OpenProject WorkPackage's body uses constructs the current - // ruff_ruby_spo (96ed65f) bails on — self-referential - // `include WorkPackage::Foo` chains + top-level `%w[…].freeze` - // constants — so its extracted surface is currently sparse. A - // ruff sprint follow-up will lift those. Until then: assert the - // structural overlap only where the producer actually extracted - // something, so the convergence proof does not depend on parser - // completeness. - if !work_package.associations.is_empty() { - assert!( - work_package.associations.iter().any(|a| a.name == "project"), - "OP WorkPackage extracted associations but no `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: any extraction-depth - /// difference between the cleaner Redmine `Issue` and the richer - /// OpenProject `WorkPackage` (extra modular includes — - /// `WorkPackages::SpentTime` / `Costs` / `Relations`) leaves the - /// canonical concept invariant. Holds both ways: when OP extracts - /// strictly more surface (the post-ruff-fix state), and when OP - /// extracts strictly less (the current ruff parser gap on - /// `WorkPackage`'s `%w[…].freeze` / self-include chain). + /// 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() { @@ -256,12 +242,47 @@ mod tests { .find(|c| c.name == "WorkPackage") .unwrap(); - // Headline: the canonical concept is identical regardless of - // extraction-depth difference. Enrichment did not break overlap. + // 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}", + ); + } } From 4146bb442893ee92cbd0eaead1e8cf988ef554fc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:50:07 +0000 Subject: [PATCH 5/5] feat(ogar-from-ruff): lineage-transcode bridge for project_work_item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OP <-> Redmine axis (per scope split: this session is OP/Redmine, the other session is Odoo/Open-Source-Billing). Prove that the fork lineage Redmine -> ChiliProject -> OpenProject transcodes losslessly through the canonical ProjectWorkItem bridge. New (ogar-from-ruff): - project_work_item_role(curator_name) -> Option<&'static str>: maps Rails-dialect association names to canonical roles. Includes both Redmine-side (tracker, relations_from/to, assigned_to) and OP-side (type, responsible) synonyms; off-shape names (fixed_version, file_links) return None. - project_work_item_role_from_mixin(mixin) -> Option<&'static str>: OP's WorkPackage gets journals via acts_as_journalized and relations via the WorkPackages::Relations concern instead of direct has_many; this recovers them so the canonical projection is total. - project_work_item_canonical_roles(&Class) -> HashSet<&'static str>: unions association- and mixin-derived roles; the lossless projection. New tests: - ogar-from-ruff: project_work_item_role_maps_rails_dialect_synonyms, project_work_item_canonical_roles_unions_associations_and_mixins. - ogar-from-rails: lineage_transcode_redmine_issue_to_openproject_work_- package_via_canonical — proves on REAL source (Redmine /tmp/redmine + OpenProject /home/user/openproject) that BOTH curators project to the IDENTICAL 9-role canonical role set. The canonical layer is a valid pivot for transcoding between curators of the same fork lineage. 22 ogar-from-ruff unit, 21 ogar-vocab, 6 real-corpus, clippy clean. --- crates/ogar-from-rails/src/lib.rs | 68 ++++++++++++++ crates/ogar-from-ruff/src/lib.rs | 146 ++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+) diff --git a/crates/ogar-from-rails/src/lib.rs b/crates/ogar-from-rails/src/lib.rs index 280ba0a..0ab4a0c 100644 --- a/crates/ogar-from-rails/src/lib.rs +++ b/crates/ogar-from-rails/src/lib.rs @@ -260,6 +260,74 @@ mod tests { ); } + /// **Lineage-transcode smoke** — Redmine `Issue` and OpenProject + /// `WorkPackage` are forks of the same upstream + /// (Redmine → ChiliProject → OpenProject), so transcoding between + /// them through the canonical [`ogar_vocab::project_work_item`] + /// bridge must be lossless: both curators project onto the **same + /// canonical role set**, even where surface names diverge + /// (`tracker`/`type`, `assigned_to`/`assignee`, `relations_from,to`/ + /// `WorkPackages::Relations`, `journals`/`acts_as_journalized`). + /// + /// The claim is structural: the canonical layer is a valid pivot for + /// converting between curators of the same lineage. + #[test] + #[ignore = "requires Redmine + OpenProject checkouts"] + fn lineage_transcode_redmine_issue_to_openproject_work_package_via_canonical() { + 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(); + + // Project each curator onto the canonical role set. + let issue_roles = ogar_from_ruff::project_work_item_canonical_roles(issue); + let wp_roles = ogar_from_ruff::project_work_item_canonical_roles(work_package); + + // The full canonical surface: every role on + // [`ogar_vocab::project_work_item`]. + let expected: std::collections::HashSet<&'static str> = [ + "project", "status", "type", "priority", "author", "assignee", + "journals", "relations", "time_entries", + ] + .into_iter() + .collect(); + + assert_eq!( + issue_roles, expected, + "Redmine Issue must project to the FULL project_work_item canonical role set", + ); + assert_eq!( + wp_roles, expected, + "OpenProject WorkPackage must project to the FULL project_work_item canonical role set \ + (journals via `acts_as_journalized` mixin; relations via `WorkPackages::Relations`)", + ); + + // The headline lineage-transcode claim: identical canonical + // projections. The canonical layer IS a lossless pivot between + // the two curators of the same fork lineage. + assert_eq!( + issue_roles, wp_roles, + "fork lineage Redmine -> ChiliProject -> OpenProject must transcode losslessly \ + through the canonical project_work_item bridge", + ); + } + /// 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 diff --git a/crates/ogar-from-ruff/src/lib.rs b/crates/ogar-from-ruff/src/lib.rs index 5ea5f3c..98e2f80 100644 --- a/crates/ogar-from-ruff/src/lib.rs +++ b/crates/ogar-from-ruff/src/lib.rs @@ -136,6 +136,77 @@ pub fn lift_model(model: &Model) -> Class { class } +// ───────────────────────── ProjectWorkItem role projection ───────────── +// +// Curator-side mapping: project_work_item's canonical roles vs the +// Rails-AR-dialect names that surface them. Used by the lineage-transcode +// smoke (Redmine Issue <-> OpenProject WorkPackage through the canonical +// bridge) — both curators must project losslessly onto the same role set. +// +// Synonyms are deliberate and minimal: only well-known Rails-AR variants +// of the canonical role appear here. Adding a new synonym is the right +// move when a real corpus surfaces it; inventing synonyms isn't. + +/// Map a Rails-curator association name to the canonical +/// [`ogar_vocab::project_work_item`] role it realises. Returns `None` +/// when the association is not part of the project-work-item canonical +/// surface (e.g. Redmine `fixed_version`, OP `file_links`). +#[must_use] +pub fn project_work_item_role(curator_name: &str) -> Option<&'static str> { + match curator_name { + "project" => Some("project"), + "status" => Some("status"), + "tracker" | "type" => Some("type"), + "priority" => Some("priority"), + "author" => Some("author"), + "assigned_to" | "assignee" | "responsible" => Some("assignee"), + "journals" => Some("journals"), + "relations" | "relations_from" | "relations_to" => Some("relations"), + "time_entries" => Some("time_entries"), + _ => None, + } +} + +/// Map a mixin / `acts_as_*` name to a canonical +/// [`ogar_vocab::project_work_item`] role it provides indirectly. OP's +/// WorkPackage carries `journals` via `acts_as_journalized` and +/// `relations` via the `WorkPackages::Relations` concern instead of +/// direct `has_many` calls; this resolver recovers them so the canonical +/// projection is total. +#[must_use] +pub fn project_work_item_role_from_mixin(mixin: &str) -> Option<&'static str> { + if mixin == "acts_as_journalized" || mixin.ends_with("::Journalized") { + return Some("journals"); + } + if mixin == "WorkPackages::Relations" || mixin.ends_with("::Relations") { + return Some("relations"); + } + None +} + +/// The set of canonical [`ogar_vocab::project_work_item`] roles a curator +/// class projects onto. Unions association-derived roles +/// ([`project_work_item_role`]) with mixin-derived roles +/// ([`project_work_item_role_from_mixin`]). Returns the empty set when +/// the class has no project-work-item shape. +#[must_use] +pub fn project_work_item_canonical_roles( + class: &Class, +) -> std::collections::HashSet<&'static str> { + let mut set = std::collections::HashSet::new(); + for a in &class.associations { + if let Some(role) = project_work_item_role(&a.name) { + set.insert(role); + } + } + for m in &class.mixins { + if let Some(role) = project_work_item_role_from_mixin(m) { + set.insert(role); + } + } + set +} + // ───────────────────────────── actions (DO-arm) ───────────────────────── /// Lift a [`Model`]'s methods to OGAR [`ActionDef`] declarations — the @@ -752,6 +823,81 @@ mod tests { assert_eq!(lift_model_graph(&other)[0].source_domain, None); } + #[test] + fn project_work_item_role_maps_rails_dialect_synonyms() { + // Universal names common to Redmine + OP. + for (src, want) in [ + ("project", "project"), + ("status", "status"), + ("priority", "priority"), + ("author", "author"), + ("time_entries", "time_entries"), + ] { + assert_eq!(project_work_item_role(src), Some(want)); + } + // Redmine-side dialect: tracker -> type; relations_from/to -> relations. + assert_eq!(project_work_item_role("tracker"), Some("type")); + assert_eq!(project_work_item_role("relations_from"), Some("relations")); + assert_eq!(project_work_item_role("relations_to"), Some("relations")); + // OP-side dialect: type -> type; responsible -> assignee. + assert_eq!(project_work_item_role("type"), Some("type")); + assert_eq!(project_work_item_role("responsible"), Some("assignee")); + // Off-shape associations return None — `fixed_version` (Redmine), + // `file_links` (OP) are real-but-not-canonical. + assert!(project_work_item_role("fixed_version").is_none()); + assert!(project_work_item_role("file_links").is_none()); + } + + #[test] + fn project_work_item_canonical_roles_unions_associations_and_mixins() { + // Redmine-shaped class: journals + relations come via direct + // associations; the union still covers them. + let mut redmine = Class::new("Issue"); + redmine.associations = vec![ + Association::new(AssociationKind::BelongsTo, "project"), + Association::new(AssociationKind::BelongsTo, "tracker"), + Association::new(AssociationKind::BelongsTo, "status"), + Association::new(AssociationKind::BelongsTo, "author"), + Association::new(AssociationKind::BelongsTo, "assigned_to"), + Association::new(AssociationKind::BelongsTo, "priority"), + Association::new(AssociationKind::HasMany, "journals"), + Association::new(AssociationKind::HasMany, "time_entries"), + Association::new(AssociationKind::HasMany, "relations_from"), + Association::new(AssociationKind::HasMany, "relations_to"), + // Off-shape: must NOT inflate the role set. + Association::new(AssociationKind::BelongsTo, "fixed_version"), + ]; + let roles = project_work_item_canonical_roles(&redmine); + assert_eq!(roles.len(), 9); + for r in [ + "project", "status", "type", "priority", "author", "assignee", + "journals", "relations", "time_entries", + ] { + assert!(roles.contains(r), "Redmine projection missing role {r}"); + } + + // OP-shaped class: journals + relations come via MIXINS + // (`acts_as_journalized`, `WorkPackages::Relations`). Same total + // role set under the lineage-transcode bridge. + let mut op = Class::new("WorkPackage"); + op.associations = vec![ + Association::new(AssociationKind::BelongsTo, "project"), + Association::new(AssociationKind::BelongsTo, "type"), + Association::new(AssociationKind::BelongsTo, "status"), + Association::new(AssociationKind::BelongsTo, "author"), + Association::new(AssociationKind::BelongsTo, "assigned_to"), + Association::new(AssociationKind::BelongsTo, "responsible"), + Association::new(AssociationKind::BelongsTo, "priority"), + Association::new(AssociationKind::HasMany, "time_entries"), + ]; + op.mixins = vec![ + "acts_as_journalized".to_string(), + "WorkPackages::Relations".to_string(), + ]; + let op_roles = project_work_item_canonical_roles(&op); + assert_eq!(op_roles, roles, "OP must project to the same canonical role set as Redmine"); + } + #[test] fn lift_model_sets_canonical_concept_including_promoted_invariant() { // Plain class with no promoted invariant → lexical concept.