Skip to content

Fix ActiveRecord dependency check for Zeitwerk eager loading#504

Merged
crmne merged 1 commit intocrmne:mainfrom
trevorturk:fix-active-record-dependency-clean
May 6, 2026
Merged

Fix ActiveRecord dependency check for Zeitwerk eager loading#504
crmne merged 1 commit intocrmne:mainfrom
trevorturk:fix-active-record-dependency-clean

Conversation

@trevorturk
Copy link
Copy Markdown
Contributor

@trevorturk trevorturk commented Nov 18, 2025

Summary

Fixes RubyLLM's optional ActiveRecord integration so the core gem can be required and Zeitwerk-eager-loaded without loading Rails-only ActiveRecord mixins too early.

The PR keeps normal Rails usage intact: in a Rails app, acts_as_chat, acts_as_message, acts_as_model, and acts_as_tool_call are still installed on ActiveRecord::Base by the RubyLLM railtie after ActiveRecord loads.

Why This Was Needed

lib/ruby_llm/active_record is optional Rails integration code. It should not be part of RubyLLM's standalone gem eager-load path.

Before this change, the gem-level Zeitwerk loader still managed lib/ruby_llm/active_record. That meant a standalone eager-load like this could load ActiveRecord mixin files even when a Rails app had not booted ActiveRecord:

bundle exec appraisal rails-7.2 ruby -e "require 'ruby_llm'; Zeitwerk::Loader.eager_load_all"

That exposed load-order assumptions inside the ActiveRecord integration. The immediate CI failure was:

NoMethodError: undefined method `delegate' for module RubyLLM::ActiveRecord::ModelMethods

The failing line was in RubyLLM::ActiveRecord::ModelMethods, which uses ActiveSupport's delegate. The broader issue was not just that one missing require; the optional ActiveRecord directory itself was being eager-loaded by the core gem loader.

How Normal Rails Loading Works

The intended Rails path is:

  1. Rails boots and loads its railties.
  2. Bundler requires ruby_llm.
  3. ruby_llm requires ruby_llm/railtie when Rails::Railtie is defined.
  4. RubyLLM::Railtie registers an ActiveSupport.on_load :active_record hook.
  5. When ActiveRecord loads, the hook explicitly loads RubyLLM's ActiveRecord support files.
  6. The railtie includes either RubyLLM::ActiveRecord::ActsAs or RubyLLM::ActiveRecord::ActsAsLegacy into ActiveRecord::Base.
  7. Application models can call the acts_as_* APIs normally.

This PR makes that boundary explicit: core RubyLLM eager-loading does not load ActiveRecord integration files; the Rails railtie loads them when ActiveRecord is available.

Changes

Isolate ActiveRecord integration from gem eager-load

lib/ruby_llm.rb now ignores lib/ruby_llm/active_record in the gem-level Zeitwerk loader:

loader.ignore("#{__dir__}/ruby_llm/active_record")

This matches the existing treatment for tasks, generators, and the railtie itself. It prevents Zeitwerk::Loader.eager_load_all from loading optional Rails integration code during standalone require "ruby_llm" usage.

Stop loading ActiveRecord mixins from ruby_llm.rb

lib/ruby_llm.rb no longer directly requires ruby_llm/active_record/acts_as.

It only requires the railtie when Rails is present:

require 'ruby_llm/railtie' if defined?(Rails::Railtie)

That keeps the Rails integration behind the railtie instead of coupling it to the core gem entrypoint.

Make the railtie explicitly own ActiveRecord integration loading

lib/ruby_llm/railtie.rb now loads the ActiveRecord support files inside ActiveSupport.on_load :active_record:

  • ruby_llm/active_record/payload_helpers
  • ruby_llm/active_record/chat_methods
  • ruby_llm/active_record/message_methods
  • ruby_llm/active_record/model_methods
  • ruby_llm/active_record/tool_call_methods

Then it loads the configured public API module:

  • ruby_llm/active_record/acts_as when RubyLLM.config.use_new_acts_as is true
  • ruby_llm/active_record/acts_as_legacy otherwise

This preserves normal Rails behavior while removing reliance on gem-level Zeitwerk autoloads for an ignored directory.

Make ActiveRecord files explicit about their dependencies

Several files previously relied on incidental load order or Zeitwerk autoloads. The PR adds explicit requires so each file is safe when loaded by the railtie:

  • acts_as.rb now requires active_support/concern and active_support/inflector.
  • acts_as_legacy.rb now requires active_support/concern and active_support/inflector.
  • chat_methods.rb now requires active_support/concern.
  • message_methods.rb now requires active_support/concern and ruby_llm/active_record/payload_helpers.
  • model_methods.rb now requires active_support/concern and active_support/core_ext/module/delegation.
  • payload_helpers.rb now requires active_support/core_ext/object/blank and json.
  • tool_call_methods.rb now requires active_support/concern and ruby_llm/active_record/payload_helpers.

These changes are intentionally small, but they are important because ignoring the ActiveRecord directory removes the previous incidental Zeitwerk autoload fallback.

Keep the standalone helper spec independent

spec/ruby_llm/active_record_tool_error_helpers_spec.rb tests MessageMethods and ToolCallMethods without booting the dummy Rails app. After isolating lib/ruby_llm/active_record from the gem Zeitwerk loader, that spec must require the helper modules it exercises directly. This keeps the test independent of suite ordering and matches the new explicit-loading contract.

Add CI coverage for the failure mode

The test matrix now runs an eager-load guard for each Rails appraisal after dependencies install:

bundle exec appraisal ${{ matrix.rails-version }} ruby -e "require 'ruby_llm'; Zeitwerk::Loader.eager_load_all"

This catches regressions where optional Rails integration code leaks into the standalone gem eager-load path.

Reviewer Notes

This diff is easier to review with whitespace hidden:

https://github.com/crmne/ruby_llm/pull/504/files?w=1

The code changes are mostly explicit require lines plus one Zeitwerk ignore. Hiding whitespace makes it easier to see that the behavioral change is the load boundary, not a broad refactor.

Validation

Verified against the current PR head (aa73ba7a) after checking out the GitHub PR branch locally.

Standalone RubyLLM eager-load:

bundle exec ruby -e 'require "ruby_llm"; Zeitwerk::Loader.eager_load_all; puts :ok'

Rails appraisal eager-load guards:

bundle exec appraisal rails-7.1 ruby -e 'require "ruby_llm"; Zeitwerk::Loader.eager_load_all; puts :ok'
bundle exec appraisal rails-7.2 ruby -e 'require "ruby_llm"; Zeitwerk::Loader.eager_load_all; puts :ok'
bundle exec appraisal rails-8.0 ruby -e 'require "ruby_llm"; Zeitwerk::Loader.eager_load_all; puts :ok'
bundle exec appraisal rails-8.1 ruby -e 'require "ruby_llm"; Zeitwerk::Loader.eager_load_all; puts :ok'

Rails integration still installs acts_as_chat through the railtie:

bundle exec appraisal rails-8.1 ruby -e 'require_relative "spec/dummy/config/application"; Rails.application.initialize!; puts ActiveRecord::Base.respond_to?(:acts_as_chat); puts ActiveRecord::Base.method(:acts_as_chat).owner'

Output confirmed:

true
RubyLLM::ActiveRecord::ActsAs::ClassMethods

Direct explicit ActiveRecord support-file loading works without relying on RubyLLM's gem Zeitwerk loader:

bundle exec ruby -Ilib -e 'module RubyLLM; module ActiveRecord; end; end; require "ruby_llm/active_record/payload_helpers"; require "ruby_llm/active_record/chat_methods"; require "ruby_llm/active_record/message_methods"; require "ruby_llm/active_record/model_methods"; require "ruby_llm/active_record/tool_call_methods"; require "ruby_llm/active_record/acts_as"; require "ruby_llm/active_record/acts_as_legacy"; puts :ok'

The CI-failing standalone helper spec also passes through rspec-queue, matching the worker loading path from the failing job:

env TEST_QUEUE_WORKERS=2 SKIP_COVERAGE=true bundle exec appraisal rails-7.1 bin/rspec-queue spec/ruby_llm/active_record_tool_error_helpers_spec.rb

Targeted ActiveRecord specs:

SKIP_COVERAGE=true bundle exec appraisal rails-8.1 rspec spec/ruby_llm/active_record spec/ruby_llm/active_record_tool_error_helpers_spec.rb

Result:

100 examples, 0 failures

RuboCop on changed Ruby files:

bundle exec rubocop --cache false spec/ruby_llm/active_record_tool_error_helpers_spec.rb lib/ruby_llm.rb lib/ruby_llm/railtie.rb lib/ruby_llm/active_record/acts_as.rb lib/ruby_llm/active_record/acts_as_legacy.rb lib/ruby_llm/active_record/chat_methods.rb lib/ruby_llm/active_record/message_methods.rb lib/ruby_llm/active_record/model_methods.rb lib/ruby_llm/active_record/payload_helpers.rb lib/ruby_llm/active_record/tool_call_methods.rb

Result:

10 files inspected, no offenses detected

Whitespace check:

git diff --check 776d92a1...HEAD

Result: no whitespace errors.

trevorturk added a commit to trevorturk/ruby_llm that referenced this pull request Nov 18, 2025
Adds validation that Zeitwerk can eager load all files without errors.
This catches autoloading issues like inflector bugs and missing
conditional checks before they reach production.

- Added check after linter step for fast fail
- Runs on all Ruby/Rails matrix combinations
- ~2 second check prevents production crashes

## Blocked

This PR depends on crmne#504 (ActiveRecord dependency fix) to pass.
Without it, the eager loading check fails with NameError.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
trevorturk added a commit to trevorturk/ruby_llm-mcp that referenced this pull request Nov 18, 2025
Adds a validation step that runs Zeitwerk::Loader.eager_load_all after
dependency installation to catch autoloading issues early in the CI pipeline.

This check helps prevent production crashes by detecting:
- Inflector configuration errors
- Constant definition issues
- Missing conditional checks for optional dependencies

The check runs quickly (~2 seconds) before the test matrix, providing
rapid feedback on structural issues.

Note: This check currently fails due to upstream ruby_llm ActiveRecord
dependency issues. See:
- crmne/ruby_llm#504
- crmne/ruby_llm#505

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Copy link
Copy Markdown
Owner

@crmne crmne left a comment

Choose a reason for hiding this comment

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

Thanks for the work here. I closed #505 so we can fold it into #504 and keep this as one complete fix.

Could you please update #504 with the following?

  1. Add the CI eager-load check from #505 into this PR.
  • We do want that guard in CI, just bundled with the code changes it depends on.
  1. Keep the Railtie ActiveSupport.on_load(:active_record) integration.
  • This is the right Rails hook: AR-related code only runs when ActiveRecord is actually loaded.
  1. Remove the top-level if defined?(ActiveRecord::Base) wrappers in AR files (acts_as.rb, acts_as_legacy.rb, chat_methods.rb, message_methods.rb, model_methods.rb).
  • Those wrappers can make a file load as a no-op if AR isn’t ready yet.
  • Later requires won’t re-run the file, so constants/modules may stay undefined.
  • Better pattern: define modules normally, and control when they are included via Railtie hooks.
  1. Add explicit requires for direct dependencies in those files (for example require "active_support/concern" where ActiveSupport::Concern is used).
  • This avoids relying on incidental load order and makes eager loading more reliable.
  1. Run the eager-load check in appraisal/matrix context (same Rails version under test).
  • That makes sure the check validates the actual gem set for each matrix target, not just the base bundle.

Reason for all of this: we keep the Rails eager-loading fix, avoid subtle load-order traps, and keep a clean path for possible non-Rails ActiveRecord support later (CLI/Sinatra).

@trevorturk trevorturk force-pushed the fix-active-record-dependency-clean branch from c024641 to eadc24d Compare March 12, 2026 19:14
@trevorturk
Copy link
Copy Markdown
Contributor Author

trevorturk commented Mar 12, 2026

Apologies for the delay. I made the updates as requested but please let me know if I misunderstood something or if there's anything else to change. Thanks!

@trevorturk trevorturk force-pushed the fix-active-record-dependency-clean branch from eadc24d to b808b6d Compare April 5, 2026 15:14
@trevorturk
Copy link
Copy Markdown
Contributor Author

Rebased this branch onto current main (ac4d5d7) and fixed the RuboCop style offense in lib/ruby_llm.rb. CI is now passing on head b808b6d: RuboCop (Fast) pass, gitleaks pass. Could you take another look when you have a moment?

bundle install
bundle exec appraisal ${{ matrix.rails-version }} bundle install

- name: Run eager-load guard (appraisal)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is not really needed. you can do it in the test helper

@@ -1,5 +1,8 @@
# frozen_string_literal: true

require 'active_support/concern'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

every require should in line 4 of railtie , after if defined?(Rails::Railtie)

Then you dont have dups.

Copilot AI review requested due to automatic review settings May 6, 2026 12:28
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review is ineligible. To be eligible to request a review, you need a paid Copilot license, or your organization must enable Copilot code review.

@crmne
Copy link
Copy Markdown
Owner

crmne commented May 6, 2026

Looks like there are some test failures. Can you please fix them @trevorturk ?

@trevorturk trevorturk force-pushed the fix-active-record-dependency-clean branch 2 times, most recently from f72a79f to 9feb1b8 Compare May 6, 2026 16:43
@trevorturk trevorturk force-pushed the fix-active-record-dependency-clean branch from 9feb1b8 to aa73ba7 Compare May 6, 2026 16:57
@trevorturk trevorturk requested review from Copilot and crmne May 6, 2026 16:59
@trevorturk
Copy link
Copy Markdown
Contributor Author

@crmne apologies for fighting with CI here... hopefully updated and all green now, but I'll keep an eye and try again if there are more failures after the heavy run!

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 6, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 86.17%. Comparing base (776d92a) to head (aa73ba7).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #504      +/-   ##
==========================================
+ Coverage   86.13%   86.17%   +0.03%     
==========================================
  Files         118      118              
  Lines        5360     5375      +15     
  Branches     1346     1345       -1     
==========================================
+ Hits         4617     4632      +15     
  Misses        743      743              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@crmne crmne merged commit 1ae2407 into crmne:main May 6, 2026
26 of 27 checks 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.

4 participants