From 36e0b144bac33bba32fe7aef97c9ea3a0cdb2edf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 12:53:05 +0000 Subject: [PATCH 1/2] feat(ogar-vocab): ProjectRelation + ProjectChangeset + ProjectWatcher (3 promotions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last project_work_item forward reference (.relations) and lands two natural neighbours that pair with already-canonical concepts: | Canonical | id | Redmine | OpenProject | |----------------------|---------|----------------|-------------| | project_relation | 0x0111 | IssueRelation | Relation | | project_changeset | 0x0112 | Changeset | Changeset | | project_watcher | 0x0113 | Watcher | Watcher | project_relation has cross-curator name divergence (Redmine 'IssueRelation' vs OP 'Relation') — same shape conceptually (directed work-item edge with from/to/type/delay-or-lag). Canonical chooses 'lag' (project- scheduling term) over Redmine's 'delay'. project_changeset pairs with project_repository (already canonical, #66): both have belongs_to :repository + belongs_to :user (the committer maps to project_actor) plus revision/comments/commit_date. project_watcher captures the polymorphic follow-relationship both curators ship with belongs_to :user + belongs_to :watchable, polymorphic. The polymorphic target stays opaque at the canonical layer; the watchable_type discriminator is the curator-local cell. - CODEBOOK entries: 0x0111-0x0113 in the project-mgmt block. - Resolver arms covering each curator name + PascalCase + plural forms (singular/plural/snake_case/PascalCase per the #66 + #67 doctrine). - 3 canonical class functions with Language::Unknown + typed attrs + obvious family edges. - Extended codebook_ids_are_domain_prefixed_and_consistent test to cover the 3 new concepts. - Extended project_mgmt_batch_promotions_each_have_a_codebook_id_and_shape with 3 new entries. - Extended project_mgmt_resolver_arms_collapse_curator_names with the alias coverage (including IssueRelation/Relation divergence). - New real-corpus test: redmine_and_openproject_relation_changeset_watcher_converge — iterates all 6 extracted classes (Redmine IssueRelation + Changeset + Watcher, OP Relation + Changeset + Watcher) and asserts canonical_concept + canonical_id match. ogar-vocab 41, ogar-from-ruff 24, real-corpus 11 (+1); clippy clean. Project-mgmt block (0x01XX) is now 19 concepts. project_work_item family edges all have canonical class targets. --- crates/ogar-from-rails/src/lib.rs | 60 +++++++++++++++++ crates/ogar-vocab/src/lib.rs | 105 ++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) diff --git a/crates/ogar-from-rails/src/lib.rs b/crates/ogar-from-rails/src/lib.rs index 3209ee2..18bad75 100644 --- a/crates/ogar-from-rails/src/lib.rs +++ b/crates/ogar-from-rails/src/lib.rs @@ -595,6 +595,66 @@ mod tests { } } + /// **ProjectRelation + Changeset + Watcher convergence** on real + /// source. Closes the last `project_work_item` forward reference + /// (`.relations`) and lands two natural neighbours that pair with + /// already-canonical concepts: + /// + /// | Canonical concept | Redmine | OpenProject | + /// |---------------------|----------------|-------------| + /// | project_relation | IssueRelation | Relation | + /// | project_changeset | Changeset | Changeset | + /// | project_watcher | Watcher | Watcher | + #[test] + #[ignore = "requires Redmine + OpenProject checkouts"] + fn redmine_and_openproject_relation_changeset_watcher_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 rel_id = ogar_vocab::canonical_concept_id("project_relation"); + let cs_id = ogar_vocab::canonical_concept_id("project_changeset"); + let w_id = ogar_vocab::canonical_concept_id("project_watcher"); + assert!(rel_id.is_some() && cs_id.is_some() && w_id.is_some()); + + for (curator, classes, table) in [ + ("Redmine", &redmine, &[ + ("IssueRelation", "project_relation", rel_id), + ("Changeset", "project_changeset", cs_id), + ("Watcher", "project_watcher", w_id), + ][..]), + ("OpenProject", &openproject, &[ + ("Relation", "project_relation", rel_id), + ("Changeset", "project_changeset", cs_id), + ("Watcher", "project_watcher", w_id), + ][..]), + ] { + for (class_name, concept, expected_id) 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} -> {concept}", + ); + assert_eq!(c.source_domain.as_deref(), Some("project")); + assert_eq!(c.canonical_id(), *expected_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 diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 5fbdbbf..2156df5 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1083,6 +1083,9 @@ const CODEBOOK: &[(&str, u16)] = &[ ("project_attachment", 0x010E), // Redmine Attachment ↔ OP Attachment ("project_comment", 0x010F), // Redmine Comment ↔ OP Comment ("project_custom_field", 0x0110), // Redmine CustomField ↔ OP CustomField + ("project_relation", 0x0111), // Redmine IssueRelation ↔ OP Relation (name divergence) + ("project_changeset", 0x0112), // Redmine Changeset ↔ OP Changeset + ("project_watcher", 0x0113), // Redmine Watcher ↔ OP Watcher // ── 0x02XX — commerce / billing / ERP domain (OSB ↔ Odoo) ── // Promoted from the parallel session's `lance-graph-ontology::ar_shape` @@ -1548,6 +1551,32 @@ pub fn canonical_concept(name: &str) -> String { ) { return "project_custom_field".to_string(); } + // ProjectRelation — work-item ↔ work-item edge. Cross-curator name + // divergence: Redmine `IssueRelation`, OpenProject `Relation`. Same + // concept (precedes/blocks/relates_to between two work items). + if matches!(lower.as_str(), + "issuerelation" | "issue_relation" + | "relation" | "relations" + | "project_relation" | "projectrelation" + ) { + return "project_relation".to_string(); + } + // ProjectChangeset — VCS commit on a [`project_repository`]. Both + // curators ship `Changeset` with the same shape (belongs_to repository + // + user, revision/comments/commit_date). + if matches!(lower.as_str(), + "changeset" | "changesets" | "project_changeset" | "projectchangeset" + ) { + return "project_changeset".to_string(); + } + // ProjectWatcher — the per-user follow-relationship on a polymorphic + // watchable. Both curators ship `Watcher` with `belongs_to :user` + + // `belongs_to :watchable, polymorphic: true`. + if matches!(lower.as_str(), + "watcher" | "watchers" | "project_watcher" | "projectwatcher" + ) { + return "project_watcher".to_string(); + } // ── Commerce / billing / ERP domain (OSB ↔ Odoo) ── // CommercialLineItem — line on a commercial document. OSB // `InvoiceLineItem`, Odoo `account_move_line` (ruff normalises @@ -2052,6 +2081,74 @@ pub fn project_custom_field() -> Class { c } +/// `IssueRelation`/`Relation` — directed work-item edge. The canonical +/// target of [`project_work_item`]'s `relations` family edge. Both +/// curators have it under divergent names (Redmine `IssueRelation` → +/// `issue_from`/`issue_to`; OP `Relation` → `from`/`to`); the canonical +/// shape uses universal `from`/`to` family edges to [`project_work_item`]. +#[must_use] +pub fn project_relation() -> Class { + let mut c = Class::new("ProjectRelation"); + c.language = Language::Unknown; + c.canonical_concept = Some("project_relation".to_string()); + c.associations = vec![ + family_edge("from", "ProjectWorkItem"), + family_edge("to", "ProjectWorkItem"), + ]; + let mut relation_type = Attribute::new("relation_type"); + relation_type.type_name = Some("string".to_string()); + // Redmine names it `delay`, OP names it `lag` — same semantic + // (offset in days between predecessor and successor). Canonical: + // `lag` (the more common project-scheduling term). + let mut lag = Attribute::new("lag"); + lag.type_name = Some("integer".to_string()); + c.attributes = vec![relation_type, lag]; + c +} + +/// `Changeset` — VCS commit on a [`project_repository`]. Both curators +/// ship `Changeset` with identical shape: `belongs_to :repository`, +/// `belongs_to :user` (committer mapped to a [`project_actor`]), with +/// `revision` / `commit_date` / `comments` scalars. +#[must_use] +pub fn project_changeset() -> Class { + let mut c = Class::new("ProjectChangeset"); + c.language = Language::Unknown; + c.canonical_concept = Some("project_changeset".to_string()); + c.associations = vec![ + family_edge("repository", "ProjectRepository"), + family_edge("user", "ProjectActor"), + ]; + let mut revision = Attribute::new("revision"); + revision.type_name = Some("string".to_string()); + let mut commit_date = Attribute::new("commit_date"); + commit_date.type_name = Some("date".to_string()); + let mut comments = Attribute::new("comments"); + comments.type_name = Some("text".to_string()); + c.attributes = vec![revision, commit_date, comments]; + c +} + +/// `Watcher` — a follow-relationship: a [`project_actor`] watches a +/// polymorphic watchable (work item, project, wiki page, …). Both +/// curators ship `Watcher` with `belongs_to :user` + `belongs_to +/// :watchable, polymorphic: true`. The polymorphic target stays opaque +/// at the canonical layer (the `watchable_type` discriminator is the +/// curator-local cell). +#[must_use] +pub fn project_watcher() -> Class { + let mut c = Class::new("ProjectWatcher"); + c.language = Language::Unknown; + c.canonical_concept = Some("project_watcher".to_string()); + c.associations = vec![ + family_edge("user", "ProjectActor"), + ]; + let mut watchable_type = Attribute::new("watchable_type"); + watchable_type.type_name = Some("string".to_string()); + c.attributes = vec![watchable_type]; + c +} + // ───────────────────────────────────────────────────────────────────── // Commerce / billing / ERP domain canonical classes (OSB ↔ Odoo). // @@ -2676,6 +2773,7 @@ mod tests { "project_membership", "project_journal", "project_repository", "project_version", "project_wiki_page", "project_query", "project_attachment", "project_comment", "project_custom_field", + "project_relation", "project_changeset", "project_watcher", ] { let id = canonical_concept_id(project_concept) .unwrap_or_else(|| panic!("{project_concept} missing from codebook")); @@ -2721,6 +2819,9 @@ mod tests { (project_attachment(), "ProjectAttachment", 0x010E), (project_comment(), "ProjectComment", 0x010F), (project_custom_field(), "ProjectCustomField", 0x0110), + (project_relation(), "ProjectRelation", 0x0111), + (project_changeset(), "ProjectChangeset", 0x0112), + (project_watcher(), "ProjectWatcher", 0x0113), ] { assert_eq!(canonical.name, name); assert_eq!(canonical.language, Language::Unknown); @@ -2773,6 +2874,10 @@ mod tests { ("project_attachment", &["Attachment", "attachments", "ProjectAttachment"]), ("project_comment", &["Comment", "comments", "ProjectComment"]), ("project_custom_field", &["CustomField", "custom_field", "ProjectCustomField"]), + // Cross-curator name divergence: Redmine `IssueRelation` ↔ OP `Relation`. + ("project_relation", &["IssueRelation", "issue_relation", "Relation", "relations", "ProjectRelation"]), + ("project_changeset", &["Changeset", "changesets", "ProjectChangeset"]), + ("project_watcher", &["Watcher", "watchers", "ProjectWatcher"]), ]; for (concept, aliases) in cases { let id = canonical_concept_id(concept).unwrap(); From ebe7631b27f6baede5cfa572602d71683c8ad071 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 13:03:56 +0000 Subject: [PATCH 2/2] =?UTF-8?q?fix(ogar-vocab):=20codex=20P2=20=E2=80=94?= =?UTF-8?q?=20add=20issue=5Frelations=20to=20project=5Frelation=20arm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex flagged that ogar_codebook('issue_relations') returned None: the promoted arm only matched the singular 'issue_relation', so the Rails- tableized plural fell through to lexical, which drops the trailing s and returns 'issue_relation' — the singular curator alias, NOT a canonical concept name. canonical_concept_id then misses on the curator alias. Same defect class as #67 (wiki_pages, custom_fields). Fix is the same: add the plural snake_case + lowercase-PascalCase forms to the promoted arm so they short-circuit before lexical: 'issuerelations' | 'issue_relations' The plural_table_aliases_resolve_to_promoted_codebook_id test gains 3 entries pinning the contract: issue_relations / issuerelations / IssueRelations all map to project_relation codebook id. ogar-vocab 41, clippy clean. (Note: same defect exists on 4 other compound concepts — time_entries / issue_statuses / issue_priorities / memberships — fixed in a follow-up PR off main since they touch already-merged canonical classes.) --- crates/ogar-vocab/src/lib.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index 2156df5..f48751e 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1556,6 +1556,7 @@ pub fn canonical_concept(name: &str) -> String { // concept (precedes/blocks/relates_to between two work items). if matches!(lower.as_str(), "issuerelation" | "issue_relation" + | "issuerelations" | "issue_relations" | "relation" | "relations" | "project_relation" | "projectrelation" ) { @@ -2849,6 +2850,15 @@ mod tests { ("project_custom_field", "custom_fields"), ("project_custom_field", "customfields"), ("project_custom_field", "CustomFields"), + // Codex P2 on PR #68 — Redmine `issue_relations` is the + // Rails-tableized form of `IssueRelation`. The single-`s` + // lexical fallback drops to `issue_relation`, which is the + // singular curator alias but NOT a canonical concept name — + // so canonical_concept_id returned None. Adding the plural + // form to the promoted arm. + ("project_relation", "issue_relations"), + ("project_relation", "issuerelations"), + ("project_relation", "IssueRelations"), ] { let canonical_id = canonical_concept_id(singular); assert!(canonical_id.is_some(), "{singular} must be in codebook");