Skip to content

Commit b08041b

Browse files
committed
feat: add ogar-from-ruff + ogar-from-rails — producer-side bridge for the Rails ecosystem
The OGAR vocab was deliberately shaped to mirror ruff_ruby_spo's C17a–c stable IR (per ogar-vocab/src/lib.rs:14: "the existing producer can be lifted in-place"), but the lifter itself didn't exist — OGAR had ogar-from-elixir for HIRO/Bardioc but no ruff/Rails equivalent. This PR adds the pair. **ogar-from-ruff** (lower-level): pure projection from ruff_spo_triplet::Model → ogar_vocab::Class. No I/O. The IR shapes already align field-for-field, so this is mostly mechanical: name → name sti.inherits_from → parent associations → associations (with rich option parsing: class_name / foreign_key / polymorphic / through / source / as / dependent / etc. AcceptsNestedAttributesFor skipped — it's a UI form helper, not a relation) validations → validations (Normalizes skipped — write-time transform, not a constraint) callbacks → callbacks (phase → event) concerns → mixins (block markers skipped) acts_as → mixins (as `acts_as_<variant>` prefix) attributes → attributes (alias/undef/store_accessor filtered; the `"type"` option lifts to Attribute.type_name) scopes → scopes / default_scope (split by kind) Long-tail ruff slots (delegations / dsl_calls / gem_dsl / dynamic_methods / refinements) pass through unfiltered today — OGAR doesn't model them at the Class level; can land as ogar-extensions/rails/* later. `strip_ruby_literal_markers` normalises the verbatim option values walk::format_hash_inline produces — strings get unquoted (`"User"` → `User`), symbols lose the leading colon (`:destroy` → `destroy`), bare consts pass through. **ogar-from-rails** (high-level wrapper): walks a Rails source tree via ruff_ruby_spo::extract, then runs each Model through ogar-from-ruff. Output is Vec<Class>, registry-ready (handing off to lance-graph-ontology::OntologyRegistry is a separate follow-up — this crate's surface ends at Class). extract(&Path) → Vec<Class> Mirrors the role ogar-from-elixir plays for HIRO. ogar-from-elixir is added to workspace members in the same PR (pre-existing oversight — it lived in crates/ but wasn't built). **Smoke-tested on live OpenProject**: 694 Class values from /home/user/openproject, matching ruff_ruby_spo::extract's count 1:1. WorkPackage shows up with its associations / mixins / callbacks lifted correctly. Tests: 9 unit tests in ogar-from-ruff (field-by-field), 1 default + 1 ignored real-corpus smoke in ogar-from-rails.
1 parent 3016c78 commit b08041b

5 files changed

Lines changed: 620 additions & 0 deletions

File tree

‎Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ members = [
1010
"crates/ogar-adapter-ttl",
1111
"crates/ogar-adapter-clickhouse-ddl",
1212
"crates/ogar-knowable-from",
13+
"crates/ogar-from-elixir",
14+
"crates/ogar-from-ruff",
15+
"crates/ogar-from-rails",
1316
]
1417

1518
[workspace.package]

‎crates/ogar-from-rails/Cargo.toml‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
[package]
2+
name = "ogar-from-rails"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
repository.workspace = true
7+
authors.workspace = true
8+
rust-version.workspace = true
9+
description = "Rails / ActiveRecord frontend for OGAR. Walks an app/models tree via `ruff_ruby_spo::extract` then lifts the resulting `ruff_spo_triplet::ModelGraph` to `Vec<ogar_vocab::Class>` via `ogar-from-ruff::lift_model_graph`. The pair (`ogar-from-ruff` + `ogar-from-rails`) is the producer-side bridge into OGAR for the Rails ecosystem — mirrors the role `ogar-from-elixir` plays for HIRO."
10+
11+
[features]
12+
default = []
13+
serde = [
14+
"dep:serde",
15+
"ogar-vocab/serde",
16+
"ogar-from-ruff/serde",
17+
]
18+
19+
[dependencies]
20+
ogar-vocab = { path = "../ogar-vocab" }
21+
ogar-from-ruff = { path = "../ogar-from-ruff" }
22+
ruff_ruby_spo = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" }
23+
serde = { workspace = true, optional = true }

‎crates/ogar-from-rails/src/lib.rs‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//! `ogar-from-rails` — Rails / ActiveRecord frontend for OGAR.
2+
//!
3+
//! Walks an `app/models/` tree via [`ruff_ruby_spo::extract`], then
4+
//! lifts the resulting [`ruff_spo_triplet::ir::ModelGraph`] to a
5+
//! `Vec<ogar_vocab::Class>` via [`ogar_from_ruff::lift_model_graph`].
6+
//! Mirrors the role [`ogar_from_elixir`] plays for the HIRO/Bardioc
7+
//! Elixir stack.
8+
//!
9+
//! ```text
10+
//! app/models/ ruff_ruby_spo::extract ogar_from_ruff::lift_model_graph
11+
//! ───────── ───────────────────────────── ──────────────────────────────────
12+
//! *.rb files → ruff_spo_triplet::ModelGraph → Vec<ogar_vocab::Class>
13+
//! │
14+
//! ▼
15+
//! lance-graph-ontology::OntologyRegistry
16+
//! ```
17+
//!
18+
//! # OpenProject coverage (the canonical Rails corpus)
19+
//!
20+
//! On the live OpenProject source tree at `/home/user/openproject`:
21+
//!
22+
//! ```text
23+
//! $ extract(Path::new("/home/user/openproject"))
24+
//! → 694 Class values
25+
//! ```
26+
//!
27+
//! Matches the count from `ruff_ruby_spo::extract` 1:1 — every Rails
28+
//! class becomes one OGAR class.
29+
//!
30+
//! # What this crate is NOT
31+
//!
32+
//! - Not a producer of `field_type` / `validation_param` / etc. SPO
33+
//! triples. Those live on `ruff_spo_triplet::expand` and feed the
34+
//! narrow / schema-codegen arm (`op-surreal-ast::from_triples` →
35+
//! SurrealQL DDL). This crate is the **wide / OGAR vocab** arm —
36+
//! they're the two sides of the §10.1 OPENPROJECT-TRANSCODING
37+
//! pattern.
38+
//! - Not a `db/schema.rb` parser. DB-column lift is a separate
39+
//! sprint; see [`ogar_from_ruff`]'s field-map table for what's
40+
//! covered today.
41+
42+
#![forbid(unsafe_code)]
43+
#![warn(missing_docs)]
44+
45+
use std::path::Path;
46+
47+
use ogar_vocab::Class;
48+
49+
/// Top-level entry: extract from a Rails source tree to a list of
50+
/// OGAR `Class` values.
51+
///
52+
/// `source_tree` should be the Rails app root (the directory containing
53+
/// `app/`), not `app/models` itself. Matches the convention of
54+
/// [`ruff_ruby_spo::extract`]: it appends `app/models` internally.
55+
///
56+
/// Empty result on a tree without `app/models`, mirroring the
57+
/// `ruff_ruby_spo` behaviour (no errors, just zero models).
58+
#[must_use]
59+
pub fn extract(source_tree: &Path) -> Vec<Class> {
60+
let graph = ruff_ruby_spo::extract(source_tree);
61+
ogar_from_ruff::lift_model_graph(&graph)
62+
}
63+
64+
#[cfg(test)]
65+
mod tests {
66+
use super::*;
67+
use std::path::PathBuf;
68+
69+
/// Empty / nonexistent source tree → empty Vec, never panics. The
70+
/// `ruff_ruby_spo` contract is "no app/models → empty graph"; this
71+
/// test pins that our lift preserves the no-panic behaviour.
72+
#[test]
73+
fn nonexistent_source_tree_yields_empty_vec() {
74+
let classes = extract(Path::new("/tmp/__definitely_does_not_exist_for_test__"));
75+
assert!(classes.is_empty(), "no app/models → no classes");
76+
}
77+
78+
/// Smoke test against the live OpenProject source tree on the dev
79+
/// image (when present). Gated behind `#[ignore]` so CI without
80+
/// the OP checkout doesn't fail; locally `cargo test -- --ignored`
81+
/// surfaces the real-corpus number.
82+
#[test]
83+
#[ignore = "requires /home/user/openproject Rails source"]
84+
fn op_source_tree_yields_expected_class_count() {
85+
let path = PathBuf::from("/home/user/openproject");
86+
if !path.exists() {
87+
eprintln!("skipping: {} not present", path.display());
88+
return;
89+
}
90+
let classes = extract(&path);
91+
assert!(
92+
classes.len() > 500,
93+
"expected ~700 classes from OP, got {}",
94+
classes.len(),
95+
);
96+
// Sanity: at least one well-known class is in the list.
97+
assert!(
98+
classes.iter().any(|c| c.name == "WorkPackage"),
99+
"WorkPackage must be in the lifted Classes",
100+
);
101+
// STI: any class with `self.inheritance_column = ...` or
102+
// `abstract_class = true` carries a `parent` slot. OpenProject's
103+
// WorkPackage extends ApplicationRecord (no STI), so we just
104+
// check the field is exposed at all — populated for some class
105+
// somewhere on the corpus (or empty if no STI exists today;
106+
// this assertion is loose-by-design until the producer emits
107+
// STI for the non-default-parent cases).
108+
let any_parent_set = classes.iter().any(|c| c.parent.is_some());
109+
// Don't hard-assert any_parent_set: OP today doesn't define
110+
// STI via the explicit Rails idiom. Just verify the slot is
111+
// reachable by accessing it.
112+
let _ = any_parent_set;
113+
}
114+
}

‎crates/ogar-from-ruff/Cargo.toml‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
[package]
2+
name = "ogar-from-ruff"
3+
version.workspace = true
4+
edition.workspace = true
5+
license.workspace = true
6+
repository.workspace = true
7+
authors.workspace = true
8+
rust-version.workspace = true
9+
description = "Lift `ruff_spo_triplet::Model` (the language-agnostic IR populated by ruff frontends like `ruff_ruby_spo`) into `ogar_vocab::Class`. Mechanical field-by-field projection — the ruff IR was deliberately shaped to mirror ogar-vocab. This crate is the producer-side seam between ruff's parser ecosystem and OGAR's canonical vocab; thin enough to also serve as the reference implementation for other `ogar-from-<lang>` crates that go through ruff."
10+
11+
[features]
12+
default = []
13+
serde = ["dep:serde", "ogar-vocab/serde"]
14+
15+
[dependencies]
16+
ogar-vocab = { path = "../ogar-vocab" }
17+
ruff_spo_triplet = { git = "https://github.com/AdaWorldAPI/ruff", branch = "main" }
18+
serde = { workspace = true, optional = true }

0 commit comments

Comments
 (0)