@@ -139,6 +139,15 @@ pub const OPENPROJECT_ALIASES: &[(&str, u16)] = &[
139139 // class name. Match the actual class so `class_id("IssuePriority")`
140140 // resolves.
141141 ( "IssuePriority" , class_ids:: PRIORITY ) ,
142+ // OpenProject's actual Rails class for `project_membership` is `Member`
143+ // (mirrors Redmine — both forks ship the join row as `Member`). The
144+ // engine-walking corpus snapshot in op-canon carries `Member`. The
145+ // earlier `Membership` alias was pre-snapshot prose; keep it as a
146+ // deprecated synonym so any consumer holding the old name still
147+ // resolves, but `Member` is the canonical OP surface for the concept.
148+ // Closes the openproject-nexgen-rs#56 pinned
149+ // `port_and_snapshot_membership_vocab_mismatch_is_known` test.
150+ ( "Member" , class_ids:: PROJECT_MEMBERSHIP ) ,
142151 ( "Membership" , class_ids:: PROJECT_MEMBERSHIP ) ,
143152 ( "Journal" , class_ids:: PROJECT_JOURNAL ) ,
144153 ( "Repository" , class_ids:: PROJECT_REPOSITORY ) ,
@@ -590,7 +599,10 @@ mod tests {
590599 ( "Status" , "IssueStatus" , class_ids:: PROJECT_STATUS ) ,
591600 ( "Type" , "Tracker" , class_ids:: PROJECT_TYPE ) ,
592601 ( "IssuePriority" , "IssuePriority" , class_ids:: PRIORITY ) ,
593- ( "Membership" , "Member" , class_ids:: PROJECT_MEMBERSHIP ) ,
602+ // Both forks ship the membership join as `Member` (engine-walking
603+ // corpus snapshot). The OpenProject port still carries the legacy
604+ // `Membership` synonym; the canonical pair is now Member ↔ Member.
605+ ( "Member" , "Member" , class_ids:: PROJECT_MEMBERSHIP ) ,
594606 ( "Journal" , "Journal" , class_ids:: PROJECT_JOURNAL ) ,
595607 ( "Repository" , "Repository" , class_ids:: PROJECT_REPOSITORY ) ,
596608 ( "Version" , "Version" , class_ids:: PROJECT_VERSION ) ,
@@ -641,6 +653,34 @@ mod tests {
641653 }
642654 }
643655
656+ /// OpenProject ships the membership join as `Member` (mirrors Redmine —
657+ /// both engine-walking corpus snapshots carry that name). The earlier
658+ /// `Membership` surface stays as a deprecated synonym so any consumer
659+ /// holding the old name still resolves; this test pins both routes to
660+ /// the same canonical id so the additive contract can't drift.
661+ ///
662+ /// Closes the openproject-nexgen-rs#56 pinned
663+ /// `port_and_snapshot_membership_vocab_mismatch_is_known` test — once
664+ /// this lands and op-canon bumps its `ogar-vocab` git pin,
665+ /// `OpenProjectPort::class_id("Member")` flips from `None` to
666+ /// `Some(PROJECT_MEMBERSHIP)`, that pin self-fails, and the consumer
667+ /// drops it.
668+ #[ test]
669+ fn openproject_member_and_membership_both_resolve_to_project_membership ( ) {
670+ let target = Some ( class_ids:: PROJECT_MEMBERSHIP ) ;
671+ // Canonical surface (matches the OpenProject corpus + Redmine):
672+ assert_eq ! ( OpenProjectPort :: class_id( "Member" ) , target) ;
673+ // Deprecated synonym kept for backward compatibility:
674+ assert_eq ! ( OpenProjectPort :: class_id( "Membership" ) , target) ;
675+ // Both ports converge under the same canonical surface name now:
676+ assert_eq ! ( RedminePort :: class_id( "Member" ) , target) ;
677+ assert_eq ! (
678+ OpenProjectPort :: class_id( "Member" ) ,
679+ RedminePort :: class_id( "Member" ) ,
680+ "OP `Member` and RM `Member` must converge on the same id" ,
681+ ) ;
682+ }
683+
644684 #[ test]
645685 fn unknown_public_names_resolve_to_none ( ) {
646686 assert_eq ! ( OpenProjectPort :: class_id( "NotAConcept" ) , None ) ;
@@ -808,20 +848,23 @@ mod tests {
808848 // classes its corpus ships, no phantom aliases for concepts
809849 // the port doesn't expose as a top-level model.
810850 //
811- // OpenProject (27 ): 25 distinct concept entries + 2 STI-fold
851+ // OpenProject (28 ): 25 distinct concept entries + 2 STI-fold
812852 // rows (Principal, Group fold into PROJECT_ACTOR alongside
813- // User). No `Comment` entry — OpenProject's Journal carries
814- // the comment-equivalent state, no standalone Comment model.
853+ // User) + 1 deprecated synonym row (Membership → Member; both
854+ // resolve to PROJECT_MEMBERSHIP, the canonical surface is
855+ // Member per the engine-walking corpus snapshot). No `Comment`
856+ // entry — OpenProject's Journal carries the comment-equivalent
857+ // state, no standalone Comment model.
815858 // Redmine (28): 26 distinct concept entries + 2 STI-fold rows.
816859 // Has a standalone `Comment` model on top of `Journal` (the
817- // one extra row vs OpenProject).
860+ // one extra row vs OpenProject's canonical concepts ).
818861 //
819862 // Both gained the same +2 STI-fold rows and +0/+1 IssuePriority
820863 // entry under codex P2 on PR #87 (Redmine previously had no
821864 // priority entry; OpenProject's was misnamed `Priority`).
822865 assert_eq ! (
823866 OpenProjectPort :: aliases( ) . len( ) ,
824- 27 ,
867+ 28 ,
825868 "OpenProject alias count drift — re-count the table"
826869 ) ;
827870 assert_eq ! (
0 commit comments