feat: add ogar-from-ruff + ogar-from-rails — producer-side bridge for the Rails ecosystem#52
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b08041be91
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // `default_scope` lives on a separate Class slot; only named | ||
| // scopes contribute to `Class.scopes`. The plural `scopes` DSL | ||
| // (OpenProject extension) is included alongside `scope`. | ||
| if !matches!(s.kind, ScopeKind::Scope | ScopeKind::Scopes) { |
There was a problem hiding this comment.
Route plural scopes to predeclarations
For OpenProject's plural scopes :a, :b DSL, ruff emits ScopeKind::Scopes, but OGAR has a dedicated Class.scope_predeclarations slot for these names. Including them in Class.scopes creates normal scope records with empty bodies while leaving scope_predeclarations empty, so downstream emitters miss the ogar:scopePredeclaration facts and instead see bogus body-less scopes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed. lift_scope now matches only ScopeKind::Scope (the singular form with a body). The plural-list Scopes kind routes to Class.scope_predeclarations via a new lift_scope_predeclarations helper, matching OGAR's intended slot semantics. Regression lift_scope_predeclarations_routes_plural_form locks it (singular :active → scopes, plural :visible, :pending → scope_predeclarations, neither leaks across).
| | AttrKind::AttrReadonly | ||
| | AttrKind::StoreAttribute | ||
| | AttrKind::Serialize | ||
| | AttrKind::Enum |
There was a problem hiding this comment.
Preserve Rails enums as EnumDecls
When a Rails model uses enum :status, ..., this match sends AttrKind::Enum through lift_attribute, so lift_model leaves Class.enums empty. Downstream schema/triple emitters read enum constraints from class.enums, so enum-backed columns lose their variant list and are emitted like ordinary attributes instead of as enum declarations.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed. AttrKind::Enum is now excluded from lift_attribute's kept set and routed through a new lift_enum helper to Class.enums as EnumDecl { column, source: EnumSource::Static(empty) }. The variant list is empty because ruff_ruby_spo::walk drops the hash on the floor (see the walker comment: "enum :status, { active: 0 }" — 1 attr (skip Hash)); a future ruff enrichment to pass variants through AttrDecl.options would extend this lift. Regression lift_enums_routes_attr_kind_enum_to_enums_slot locks the routing (enum → enums slot only, doesn't double-emit as attribute).
| // ───────────────────────────── callbacks ──────────────────────────────── | ||
|
|
||
| fn lift_callback(cb: &RuffCallback) -> Callback { | ||
| Callback::method(&cb.phase, &cb.target) |
There was a problem hiding this comment.
Avoid empty callback target methods
For block-form callbacks such as before_save { ... }, ruff has no method-symbol argument and the callback target is an empty string; wrapping every callback with Callback::method turns that into target_method = Some(""). The OGAR emitter then produces an ogar:targetMethod triple with an empty object, so block callbacks are misrepresented as calls to an empty method name rather than having no target method.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Good catch — fixed. lift_callback now branches on empty target: block-form callbacks (where ruff drops the block body) produce a Callback with target_method = None AND body_source = None, not Some(""). Built via Callback::default() + mutation because the struct is #[non_exhaustive] and the ::method / ::block constructors both populate one side. Regression lift_callback_block_form_yields_no_target_method locks the method-form vs block-form distinction.
… 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.
b08041b to
e6a916f
Compare
Summary
The OGAR vocab was deliberately shaped to mirror
ruff_ruby_spo's C17a–c stable IR (perogar-vocab/src/lib.rs:14: "the existing producer can be lifted in-place"), but the lifter itself didn't exist — OGAR hadogar-from-elixirfor HIRO/Bardioc but no ruff/Rails equivalent. This PR adds the pair.Two crates, layered
ogar-from-ruff(lower-level): pure projection fromruff_spo_triplet::Model→ogar_vocab::Class. No I/O.namenamesti.inherits_fromparentassociationsassociationsclass_name/foreign_key/polymorphic/through/source/as/dependent/optional/inverse_of/before_*/after_*.AcceptsNestedAttributesForskipped (UI form helper, not a relation)validationsvalidationsNormalizesskipped (write-time transform, not constraint)callbackscallbacksphase→eventconcernsmixinsClassMethodsBlock/IncludedBlock) skippedacts_asmixinsacts_as_<variant>prefixattributesattributesalias/undef/store_accessorfiltered;"type"option lifts toAttribute.type_namescopesscopes/default_scopestrip_ruby_literal_markersnormalises verbatim option values fromwalk::format_hash_inline: strings get unquoted ("User"→User), symbols lose the leading colon (:destroy→destroy), bare consts pass through.Long-tail ruff slots (
delegations/dsl_calls/gem_dsl/dynamic_methods/refinements) pass through unfiltered today — OGAR doesn't model them at theClasslevel; can land asogar-extensions/rails/*later.ogar-from-rails(high-level): walks a Rails source tree viaruff_ruby_spo::extract, then runs each Model throughogar-from-ruff.Mirrors the role
ogar-from-elixirplays for HIRO.ogar-from-elixiris added to workspacemembersin the same PR (pre-existing oversight — it lived incrates/but wasn't built).Smoke-tested on live OpenProject
Matches
ruff_ruby_spo::extract's count 1:1. WorkPackage's associations / mixins / callbacks all lift correctly.Position in the pipeline (post-lance-graph#534)
The downstream handoff to
OntologyRegistry::register_class_path+class_id_for_guid(which #534 just made callable) is a separate follow-up — this PR's surface ends atClass.Test plan
ogar-from-ruff(field-by-field lift, marker stripping, AcceptsNestedAttributesFor / Normalizes filtering, kind separation)ogar-from-railscargo test -p ogar-from-ruff -p ogar-from-railsclean