Skip to content

feat: add ogar-from-ruff + ogar-from-rails — producer-side bridge for the Rails ecosystem#52

Merged
AdaWorldAPI merged 1 commit into
mainfrom
claude/ogar-from-rails-and-ruff
Jun 18, 2026
Merged

feat: add ogar-from-ruff + ogar-from-rails — producer-side bridge for the Rails ecosystem#52
AdaWorldAPI merged 1 commit into
mainfrom
claude/ogar-from-rails-and-ruff

Conversation

@AdaWorldAPI

Copy link
Copy Markdown
Owner

Summary

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.

Two crates, layered

ogar-from-ruff (lower-level): pure projection from ruff_spo_triplet::Modelogar_vocab::Class. No I/O.

ruff (Model) ogar (Class) notes
name name verbatim
sti.inherits_from parent STI parent
associations associations rich option parsing: class_name / foreign_key / polymorphic / through / source / as / dependent / optional / inverse_of / before_* / after_*. AcceptsNestedAttributesFor skipped (UI form helper, not a relation)
validations validations Normalizes skipped (write-time transform, not constraint)
callbacks callbacks phaseevent
concerns mixins block markers (ClassMethodsBlock / IncludedBlock) skipped
acts_as mixins rendered as acts_as_<variant> prefix
attributes attributes alias / undef / store_accessor filtered; "type" option lifts to Attribute.type_name
scopes scopes / default_scope split by kind

strip_ruby_literal_markers normalises verbatim option values from walk::format_hash_inline: strings get unquoted ("User"User), symbols lose the leading colon (:destroydestroy), 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 the Class level; can land as ogar-extensions/rails/* later.

ogar-from-rails (high-level): walks a Rails source tree via ruff_ruby_spo::extract, then runs each Model through ogar-from-ruff.

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

$ extract(Path::new("/home/user/openproject"))
→ 694 Class values

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)

app/models/      ruff_ruby_spo::extract        ogar_from_ruff::lift_model_graph
  ─────────  ─────────────────────────────  ──────────────────────────────────
  *.rb files →  ruff_spo_triplet::ModelGraph  →  Vec<ogar_vocab::Class>
                                                          │
                                                          ▼
                                       lance-graph-ontology::OntologyRegistry
                                       (→ class_id_for_guid → RegistryClassView)

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 at Class.

Test plan

  • 9 unit tests in ogar-from-ruff (field-by-field lift, marker stripping, AcceptsNestedAttributesFor / Normalizes filtering, kind separation)
  • 1 default + 1 ignored real-corpus smoke in ogar-from-rails
  • Real-corpus smoke confirms 694 Classes from live OpenProject (matches ruff_ruby_spo's count exactly)
  • cargo test -p ogar-from-ruff -p ogar-from-rails clean

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread crates/ogar-from-ruff/src/lib.rs Outdated
// `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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread crates/ogar-from-ruff/src/lib.rs Outdated
| AttrKind::AttrReadonly
| AttrKind::StoreAttribute
| AttrKind::Serialize
| AttrKind::Enum

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@AdaWorldAPI AdaWorldAPI force-pushed the claude/ogar-from-rails-and-ruff branch from b08041b to e6a916f Compare June 18, 2026 09:00
@AdaWorldAPI AdaWorldAPI merged commit d36ebd5 into main Jun 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants