Skip to content

Bump zeitwerk from 2.7.4 to 2.7.5#886

Open
dependabot[bot] wants to merge 1 commit intomainfrom
dependabot/bundler/zeitwerk-2.7.5
Open

Bump zeitwerk from 2.7.4 to 2.7.5#886
dependabot[bot] wants to merge 1 commit intomainfrom
dependabot/bundler/zeitwerk-2.7.5

Conversation

@dependabot
Copy link
Contributor

@dependabot dependabot bot commented on behalf of github Feb 20, 2026

Bumps zeitwerk from 2.7.4 to 2.7.5.

Changelog

Sourced from zeitwerk's changelog.

2.7.5 (19 Feb 2026)

  • If available, tree traversal is based on Dir.scan, which saves syscalls in common platforms. This method is a recent addition to Ruby contributed by @​byroot, so you need to be on Ruby master to leverage this for now.

  • Tree traversal is a tad more performant, regardless of the previous point. Gains are marginal when eager loading, because it is dominated by loading the code, but Zeitwerk::Loader#all_expected_cpaths was 14% faster in some benchmarks, for example.

  • README.md documents how to collect autoloaded constants using an on_load callback.

  • Internal maintenance.

Commits
  • adfeec4 Ready for 2.7.5
  • a22d742 Use the now yielded cwd in a few tests
  • 5df497f Adds unit tests for Zeitwerk::Loader::FileSystem
  • 0a7021a Let with_(files|setup) yield the cwd
  • 976b8f1 Update code comment
  • 8398da8 Let the log method take a block
  • 812d0ee Use Dir.scan if available
  • f845a27 Delete PoC file
  • 112cfdf directory -> dir for consistency
  • 064b76c Add a section about predicates to PROJECT_RULES.md
  • Additional commits viewable in compare view

Dependabot compatibility score

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting @dependabot rebase.


Dependabot commands and options

You can trigger Dependabot actions by commenting on this PR:

  • @dependabot rebase will rebase this PR
  • @dependabot recreate will recreate this PR, overwriting any edits that have been made to it
  • @dependabot show <dependency name> ignore conditions will show all of the ignore conditions of the specified dependency
  • @dependabot ignore this major version will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this minor version will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
  • @dependabot ignore this dependency will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)

Bumps [zeitwerk](https://github.com/fxn/zeitwerk) from 2.7.4 to 2.7.5.
- [Changelog](https://github.com/fxn/zeitwerk/blob/main/CHANGELOG.md)
- [Commits](fxn/zeitwerk@v2.7.4...v2.7.5)

---
updated-dependencies:
- dependency-name: zeitwerk
  dependency-version: 2.7.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot bot added dependencies ruby Pull requests that update Ruby code labels Feb 20, 2026
@github-actions
Copy link
Contributor

4 similar comments
@github-actions
Copy link
Contributor

@github-actions
Copy link
Contributor

@github-actions
Copy link
Contributor

@github-actions
Copy link
Contributor

@github-actions
Copy link
Contributor

gem compare zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT date:
    2.7.4: 2025-12-16 00:00:00 UTC
    2.7.5: 1980-01-02 00:00:00 UTC
  DIFFERENT rubygems_version:
    2.7.4: 3.4.19
    2.7.5: 4.0.3
  DIFFERENT version:
    2.7.4: 2.7.4
    2.7.5: 2.7.5
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
            lib/zeitwerk/loader/file_system.rb +165/-0
      * Changed:
            README.md +19/-0
            lib/zeitwerk/gem_loader.rb +2/-2
            lib/zeitwerk/loader.rb +43/-48
            lib/zeitwerk/loader/callbacks.rb +3/-3
            lib/zeitwerk/loader/config.rb +9/-9
            lib/zeitwerk/loader/eager_load.rb +26/-28
            lib/zeitwerk/loader/helpers.rb +1/-100
            lib/zeitwerk/real_mod_name.rb +1/-1
            lib/zeitwerk/registry.rb +26/-0
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

3 similar comments
@github-actions
Copy link
Contributor

gem compare zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT date:
    2.7.4: 2025-12-16 00:00:00 UTC
    2.7.5: 1980-01-02 00:00:00 UTC
  DIFFERENT rubygems_version:
    2.7.4: 3.4.19
    2.7.5: 4.0.3
  DIFFERENT version:
    2.7.4: 2.7.4
    2.7.5: 2.7.5
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
            lib/zeitwerk/loader/file_system.rb +165/-0
      * Changed:
            README.md +19/-0
            lib/zeitwerk/gem_loader.rb +2/-2
            lib/zeitwerk/loader.rb +43/-48
            lib/zeitwerk/loader/callbacks.rb +3/-3
            lib/zeitwerk/loader/config.rb +9/-9
            lib/zeitwerk/loader/eager_load.rb +26/-28
            lib/zeitwerk/loader/helpers.rb +1/-100
            lib/zeitwerk/real_mod_name.rb +1/-1
            lib/zeitwerk/registry.rb +26/-0
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

@github-actions
Copy link
Contributor

gem compare zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT date:
    2.7.4: 2025-12-16 00:00:00 UTC
    2.7.5: 1980-01-02 00:00:00 UTC
  DIFFERENT rubygems_version:
    2.7.4: 3.4.19
    2.7.5: 4.0.3
  DIFFERENT version:
    2.7.4: 2.7.4
    2.7.5: 2.7.5
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
            lib/zeitwerk/loader/file_system.rb +165/-0
      * Changed:
            README.md +19/-0
            lib/zeitwerk/gem_loader.rb +2/-2
            lib/zeitwerk/loader.rb +43/-48
            lib/zeitwerk/loader/callbacks.rb +3/-3
            lib/zeitwerk/loader/config.rb +9/-9
            lib/zeitwerk/loader/eager_load.rb +26/-28
            lib/zeitwerk/loader/helpers.rb +1/-100
            lib/zeitwerk/real_mod_name.rb +1/-1
            lib/zeitwerk/registry.rb +26/-0
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

@github-actions
Copy link
Contributor

gem compare zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT date:
    2.7.4: 2025-12-16 00:00:00 UTC
    2.7.5: 1980-01-02 00:00:00 UTC
  DIFFERENT rubygems_version:
    2.7.4: 3.4.19
    2.7.5: 4.0.3
  DIFFERENT version:
    2.7.4: 2.7.4
    2.7.5: 2.7.5
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
            lib/zeitwerk/loader/file_system.rb +165/-0
      * Changed:
            README.md +19/-0
            lib/zeitwerk/gem_loader.rb +2/-2
            lib/zeitwerk/loader.rb +43/-48
            lib/zeitwerk/loader/callbacks.rb +3/-3
            lib/zeitwerk/loader/config.rb +9/-9
            lib/zeitwerk/loader/eager_load.rb +26/-28
            lib/zeitwerk/loader/helpers.rb +1/-100
            lib/zeitwerk/real_mod_name.rb +1/-1
            lib/zeitwerk/registry.rb +26/-0
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

@github-actions
Copy link
Contributor

gem compare --diff zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/20260220-408-xtugdb	2026-02-20 03:33:17.222227403 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-02-20 03:33:17.219227414 +0000
                @@ -0,0 +1,165 @@
                +# frozen_string_literal: true
                +
                +# This private class encapsulates interactions with the file system.
                +#
                +# It is used to list directories and check file types, and it encodes the
                +# conventions documented in the README.
                +#
                +# @private
                +class Zeitwerk::Loader::FileSystem # :nodoc:
                +  #: (Zeitwerk::Loader) -> void
                +  def initialize(loader)
                +    @loader = loader
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  def ls(dir)
                +    children = relevant_dir_entries(dir)
                +
                +    # The order in which a directory is listed depends on the file system.
                +    #
                +    # Since client code may run on different platforms, it seems convenient to
                +    # sort directory entries. This provides more deterministic behavior, with
                +    # consistent eager loading in particular.
                +    children.sort_by!(&:first)
                +
                +    children.each do |basename, abspath, ftype|
                +      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                +        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +        next
                +      end
                +
                +      yield basename, abspath, ftype
                +    end
                +  end
                +
                +  #: (String) { (String) -> void } -> void
                +  def walk_up(abspath)
                +    loop do
                +      yield abspath
                +      abspath, basename = File.split(abspath)
                +      break if basename == "/"
                +    end
                +  end
                +
                +  # Encodes the documented conventions.
                +  #
                +  #: (String) -> Symbol?
                +  def supported_ftype?(abspath)
                +    if rb_extension?(abspath)
                +      :file # By convention, we can avoid a syscall here.
                +    elsif dir?(abspath)
                +      :directory
                +    end
                +  end
                +
                +  #: (String) -> bool
                +  def rb_extension?(path)
                +    path.end_with?(".rb")
                +  end
                +
                +  #: (String) -> bool
                +  def dir?(path)
                +    File.directory?(path)
                +  end
                +
                +  #: (String) -> bool
                +  def hidden?(basename)
                +    basename.start_with?(".")
                +  end
                +
                +  private
                +
                +  # Looks for a Ruby file using breadth-first search. This type of search is
                +  # important to list as less directories as possible and return fast in the
                +  # common case in which there are Ruby files in the passed directory.
                +  #
                +  #: (String) -> bool
                +  def has_at_least_one_ruby_file?(dir)
                +    to_visit = [dir]
                +
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |_, abspath, ftype|
                +        return true if :file == ftype
                +        to_visit << abspath
                +      end
                +    end
                +
                +    false
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  #: (String) -> [[String, String, Symbol]]
                +  def relevant_dir_entries(dir)
                +    return enum_for(__method__, dir).to_a unless block_given?
                +
                +    each_ruby_file_or_directory(dir) do |basename, abspath, ftype|
                +      next if @loader.__ignored_path?(abspath)
                +
                +      if :link == ftype
                +        begin
                +          ftype = File.stat(abspath).ftype.to_sym
                +        rescue Errno::ENOENT
                +          warn "ignoring broken symlink #{abspath}"
                +          next
                +        end
                +      end
                +
                +      if :file == ftype
                +        yield basename, abspath, ftype if rb_extension?(basename)
                +      elsif :directory == ftype
                +        # Conceptually, root directories represent a separate project tree.
                +        yield basename, abspath, ftype unless @loader.__root_dir?(abspath)
                +      end
                +    end
                +  end
                +
                +  # Dir.scan is more efficient in common platforms, but it is going to take a
                +  # while for it to be available.
                +  #
                +  # The following compatibility methods have the same semantics but are written
                +  # to favor the performance of the Ruby fallback, which can save syscalls.
                +  #
                +  # In particular, by convention, any directory entry with a .rb extension is
                +  # assumed to be a file or a symlink to a file.
                +  #
                +  # These methods also freeze abspaths because that saves allocations when
                +  # passed later to File methods. See https://github.com/fxn/zeitwerk/pull/125.
                +
                +  if Dir.respond_to?(:scan) # Available in Ruby 4.1.
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.scan(dir) do |basename, ftype|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        elsif :directory == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory
                +        elsif :link == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory if dir?(abspath)
                +        end
                +      end
                +    end
                +  else
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.each_child(dir) do |basename|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        else
                +          abspath = File.join(dir, basename).freeze
                +          if dir?(abspath)
                +            yield basename, abspath, :directory
                +          end
                +        end
                +      end
                +    end
                +  end
                +end
      * Changed:
        README.md
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/README.md	2026-02-20 03:33:17.213227435 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/README.md	2026-02-20 03:33:17.217227421 +0000
                @@ -60,0 +61 @@
                +    - [Autoloaded Constants](#autoloaded-constants)
                @@ -1265,0 +1267,12 @@
                +<a id="markdown-autoloaded-constants" name="autoloaded-constants"></a>
                +#### Autoloaded Constants
                +
                +Zeitwerk does not keep track of autoloaded constants to minimize its memory footprint, but you can collect them with `on_load` if you will:
                +
                +```ruby
                +autoloaded_cpaths = []
                +loader.on_load do |cpath, _value, _abspath|
                +  autoloaded_cpaths << cpath
                +end
                +```
                +
                @@ -1408,0 +1422,6 @@
                +```
                +
                +That also accepts a line number:
                +
                +```
                +bin/test test/lib/zeitwerk/test_eager_load.rb:52
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:17.214227431 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:17.218227417 +0000
                @@ -45 +45 @@
                -      ls(@root_dir) do |basename, abspath, ftype|
                +      @fs.ls(@root_dir) do |basename, abspath, ftype|
                @@ -50 +50 @@
                -        cname = inflector.camelize(basename_without_ext, abspath).to_sym
                +        cname = cname_for(basename_without_ext, abspath)
        lib/zeitwerk/loader.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/loader.rb	2026-02-20 03:33:17.214227431 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-02-20 03:33:17.218227417 +0000
                @@ -11,0 +12 @@
                +    require_relative "loader/file_system"
                @@ -21,3 +21,0 @@
                -    MUTEX = Mutex.new #: Mutex
                -    private_constant :MUTEX
                -
                @@ -117,0 +116 @@
                +      @fs              = FileSystem.new(self)
                @@ -174 +173 @@
                -            unloaded_files.add(abspath) if ruby?(abspath)
                +            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -192 +191 @@
                -          unloaded_files.add(abspath) if ruby?(abspath)
                +          unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -202 +201 @@
                -          #   https://github.com/Shopify/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                @@ -258 +257 @@
                -          ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir) do |basename, abspath, ftype|
                @@ -261 +260 @@
                -              result[abspath] = prefix + inflector.camelize(basename, abspath)
                +              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266 +265 @@
                -                queue << [abspath, prefix + inflector.camelize(basename, abspath)]
                +                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                @@ -282 +281,3 @@
                -      return unless dir?(abspath) || ruby?(abspath)
                +      ftype = @fs.supported_ftype?(abspath)
                +      return unless ftype
                +
                @@ -287 +288 @@
                -      if ruby?(abspath)
                +      if :file == ftype
                @@ -289 +290 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -299 +300 @@
                -      walk_up(walk_up_from) do |dir|
                +      @fs.walk_up(walk_up_from) do |dir|
                @@ -304 +305 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -365,0 +367,10 @@
                +    #: { () -> String } -> void
                +    internal def log
                +      return unless logger
                +
                +      message = yield
                +      method_name = logger.respond_to?(:debug) ? :debug : :call
                +      logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                +    end
                +
                +
                @@ -467 +478 @@
                -      ls(dir) do |basename, abspath, ftype|
                +      @fs.ls(dir) do |basename, abspath, ftype|
                @@ -486 +497 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -507 +518 @@
                -        log("the namespace #{cref} already exists, descending into #{subdir}") if logger
                +        log { "the namespace #{cref} already exists, descending into #{subdir}" }
                @@ -516 +527 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -518 +529 @@
                -          log("file #{file} is ignored because #{autoload_path} has precedence") if logger
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                @@ -524 +535 @@
                -        log("file #{file} is ignored because #{cref} is already defined") if logger
                +        log { "file #{file} is ignored because #{cref} is already defined" }
                @@ -538 +549 @@
                -      log("earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}") if logger
                +      log { "earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}" }
                @@ -551,2 +562,2 @@
                -        if ruby?(abspath)
                -          log("autoload set for #{cref}, to be loaded from #{abspath}")
                +        if @fs.rb_extension?(abspath)
                +          log { "autoload set for #{cref}, to be loaded from #{abspath}" }
                @@ -554 +565 @@
                -          log("autoload set for #{cref}, to be autovivified from #{abspath}")
                +          log { "autoload set for #{cref}, to be autovivified from #{abspath}" }
                @@ -598,22 +609,6 @@
                -    private def raise_if_conflicting_directory(dir)
                -      MUTEX.synchronize do
                -        Registry.loaders.each do |loader|
                -          next if loader == self
                -
                -          loader.__roots.each_key do |root_dir|
                -            # Conflicting directories are rare, optimize for the common case.
                -            next if !dir.start_with?(root_dir) && !root_dir.start_with?(dir)
                -
                -            dir_slash = dir + "/"
                -            root_dir_slash = root_dir + "/"
                -            next if !dir_slash.start_with?(root_dir_slash) && !root_dir_slash.start_with?(dir_slash)
                -
                -            next if ignores?(root_dir)
                -            break if loader.__ignores?(dir)
                -
                -            require "pp" # Needed to have pretty_inspect available.
                -            raise Error,
                -              "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{dir}," \
                -              " which is already managed by\n\n#{loader.pretty_inspect}\n"
                -          end
                -        end
                +    private def raise_if_conflicting_root_dir(root_dir)
                +      if loader = Registry.conflicting_root_dir?(self, root_dir)
                +        require "pp" # Needed to have pretty_inspect available.
                +        raise Error,
                +          "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{root_dir}," \
                +          " which is already managed by\n\n#{loader.pretty_inspect}\n"
                @@ -633 +628 @@
                -      log("autoload for #{cref} removed") if logger
                +      log { "autoload for #{cref} removed" }
                @@ -645 +640 @@
                -      log("#{cref} unloaded") if logger
                +      log { "#{cref} unloaded" }
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:17.214227431 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:17.218227417 +0000
                @@ -15 +15 @@
                -      log("constant #{cref} loaded from file #{file}") if logger
                +      log { "constant #{cref} loaded from file #{file}" }
                @@ -20 +20 @@
                -      log(msg) if logger
                +      log { msg }
                @@ -55 +55 @@
                -        log("module #{cref} autovivified from directory #{dir}") if logger
                +        log { "module #{cref} autovivified from directory #{dir}" }
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:17.214227431 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:17.218227417 +0000
                @@ -119,2 +119,2 @@
                -    if dir?(abspath)
                -      raise_if_conflicting_directory(abspath)
                +    if @fs.dir?(abspath)
                +      raise_if_conflicting_root_dir(abspath)
                @@ -293 +293 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -295 +295 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
                @@ -302 +302 @@
                -  private def ignored_path?(abspath)
                +  internal def ignored_path?(abspath)
                @@ -309 +309 @@
                -      !dir?(root_dir) || ignored_path?(root_dir)
                +      !@fs.dir?(root_dir) || ignored_path?(root_dir)
                @@ -314 +314 @@
                -  private def root_dir?(dir)
                +  internal def root_dir?(dir)
                @@ -323 +323 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -325 +325 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:17.215227428 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:17.218227417 +0000
                @@ -14 +14 @@
                -      log("eager load start") if logger
                +      log { "eager load start" }
                @@ -27 +27 @@
                -      log("eager load end") if logger
                +      log { "eager load end" }
                @@ -37 +37 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless @fs.dir?(abspath)
                @@ -39 +39 @@
                -    cnames = []
                +    paths = []
                @@ -42 +42 @@
                -    walk_up(abspath) do |dir|
                +    @fs.walk_up(abspath) do |dir|
                @@ -49 +49 @@
                -      return if hidden?(basename)
                +      return if @fs.hidden?(basename)
                @@ -51,3 +51 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -61 +59,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -120 +119 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if !@fs.rb_extension?(abspath)
                @@ -123,4 +122,2 @@
                -    basename = File.basename(abspath, ".rb")
                -    raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                -
                -    base_cname = inflector.camelize(basename, abspath).to_sym
                +    file_basename = File.basename(abspath, ".rb")
                +    raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(file_basename)
                @@ -129 +126 @@
                -    cnames = []
                +    paths = []
                @@ -131 +128 @@
                -    walk_up(File.dirname(abspath)) do |dir|
                +    @fs.walk_up(File.dirname(abspath)) do |dir|
                @@ -137 +134 @@
                -      raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                +      raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(basename)
                @@ -139,3 +136 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -145,0 +141,2 @@
                +    base_cname = cname_for(file_basename, abspath)
                +
                @@ -147 +144,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -164 +162 @@
                -    log("eager load directory #{dir} start") if logger
                +    log { "eager load directory #{dir} start" }
                @@ -168 +166 @@
                -      ls(current_dir) do |basename, abspath, ftype|
                +      @fs.ls(current_dir) do |basename, abspath, ftype|
                @@ -179 +177 @@
                -            cname = inflector.camelize(basename, abspath).to_sym
                +            cname = cname_for(basename, abspath)
                @@ -186 +184 @@
                -    log("eager load directory #{dir} end") if logger
                +    log { "eager load directory #{dir} end" }
                @@ -211 +209 @@
                -        ls(dir) do |basename, abspath, ftype|
                +        @fs.ls(dir) do |basename, abspath, ftype|
                @@ -216 +214 @@
                -          elsif segment == inflector.camelize(basename, abspath)
                +          elsif segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:17.215227428 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:17.219227414 +0000
                @@ -4,99 +3,0 @@
                -  # --- Logging -----------------------------------------------------------------------------------
                -
                -  #: (to_s() -> String) -> void
                -  private def log(message)
                -    method_name = logger.respond_to?(:debug) ? :debug : :call
                -    logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                -  end
                -
                -  # --- Files and directories ---------------------------------------------------------------------
                -
                -  #: (String) { (String, String, Symbol) -> void } -> void
                -  private def ls(dir)
                -    children = Dir.children(dir)
                -
                -    # The order in which a directory is listed depends on the file system.
                -    #
                -    # Since client code may run in different platforms, it seems convenient to
                -    # order directory entries. This provides consistent eager loading across
                -    # platforms, for example.
                -    children.sort!
                -
                -    children.each do |basename|
                -      next if hidden?(basename)
                -
                -      abspath = File.join(dir, basename)
                -      next if ignored_path?(abspath)
                -
                -      if dir?(abspath)
                -        next if roots.key?(abspath)
                -
                -        if !has_at_least_one_ruby_file?(abspath)
                -          log("directory #{abspath} is ignored because it has no Ruby files") if logger
                -          next
                -        end
                -
                -        ftype = :directory
                -      else
                -        next unless ruby?(abspath)
                -        ftype = :file
                -      end
                -
                -      # We freeze abspath because that saves allocations when passed later to
                -      # File methods. See #125.
                -      yield basename, abspath.freeze, ftype
                -    end
                -  end
                -
                -  # Looks for a Ruby file using breadth-first search. This type of search is
                -  # important to list as less directories as possible and return fast in the
                -  # common case in which there are Ruby files.
                -  #
                -  #: (String) -> bool
                -  private def has_at_least_one_ruby_file?(dir)
                -    to_visit = [dir]
                -
                -    while (dir = to_visit.shift)
                -      Dir.each_child(dir) do |basename|
                -        next if hidden?(basename)
                -
                -        abspath = File.join(dir, basename)
                -        next if ignored_path?(abspath)
                -
                -        if dir?(abspath)
                -          to_visit << abspath unless roots.key?(abspath)
                -        else
                -          return true if ruby?(abspath)
                -        end
                -      end
                -    end
                -
                -    false
                -  end
                -
                -  #: (String) -> bool
                -  private def ruby?(path)
                -    path.end_with?(".rb")
                -  end
                -
                -  #: (String) -> bool
                -  private def dir?(path)
                -    File.directory?(path)
                -  end
                -
                -  #: (String) -> bool
                -  private def hidden?(basename)
                -    basename.start_with?(".")
                -  end
                -
                -  #: (String) { (String) -> void } -> void
                -  private def walk_up(abspath)
                -    loop do
                -      yield abspath
                -      abspath, basename = File.split(abspath)
                -      break if basename == "/"
                -    end
                -  end
                -
                -  # --- Inflection --------------------------------------------------------------------------------
                -
                @@ -127 +28 @@
                -      path_type = ruby?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
        lib/zeitwerk/real_mod_name.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:17.215227428 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:17.219227414 +0000
                @@ -10 +10 @@
                -  # We need this indirection becasue the `name` method can be overridden, and
                +  # We need this indirection because the `name` method can be overridden, and
        lib/zeitwerk/registry.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/registry.rb	2026-02-20 03:33:17.215227428 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-02-20 03:33:17.220227410 +0000
                @@ -46,0 +47,25 @@
                +      #: (Zeitwerk::Loader, String) -> Zeitwerk::Loader?
                +      def conflicting_root_dir?(loader, new_root_dir)
                +        @mutex.synchronize do
                +          loaders.each do |existing_loader|
                +            next if existing_loader == loader
                +
                +            existing_loader.__roots.each_key do |existing_root_dir|
                +              # Conflicting directories are rare, optimize for the common case.
                +              next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir)
                +
                +              new_root_dir_slash = new_root_dir + "/"
                +              existing_root_dir_slash = existing_root_dir + "/"
                +              next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash)
                +
                +              next if loader.__ignores?(existing_root_dir)
                +              break if existing_loader.__ignores?(new_root_dir)
                +
                +              return existing_loader
                +            end
                +          end
                +
                +          nil
                +        end
                +      end
                +
                @@ -61,0 +87 @@
                +    @mutex                    = Mutex.new
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:17.215227428 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:17.221227407 +0000
                @@ -9,2 +9,2 @@
                -    def each(&block)
                -      @loaders.each(&block)
                +    def each(&)
                +      @loaders.each(&)
        lib/zeitwerk/version.rb
                --- /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.4/lib/zeitwerk/version.rb	2026-02-20 03:33:17.215227428 +0000
                +++ /tmp/d20260220-408-j9xm9w/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-02-20 03:33:17.221227407 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.4"
                +  VERSION = "2.7.5"

@github-actions
Copy link
Contributor

gem compare --diff zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/20260220-425-wdniue	2026-02-20 03:33:18.881928902 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-02-20 03:33:18.880928900 +0000
                @@ -0,0 +1,165 @@
                +# frozen_string_literal: true
                +
                +# This private class encapsulates interactions with the file system.
                +#
                +# It is used to list directories and check file types, and it encodes the
                +# conventions documented in the README.
                +#
                +# @private
                +class Zeitwerk::Loader::FileSystem # :nodoc:
                +  #: (Zeitwerk::Loader) -> void
                +  def initialize(loader)
                +    @loader = loader
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  def ls(dir)
                +    children = relevant_dir_entries(dir)
                +
                +    # The order in which a directory is listed depends on the file system.
                +    #
                +    # Since client code may run on different platforms, it seems convenient to
                +    # sort directory entries. This provides more deterministic behavior, with
                +    # consistent eager loading in particular.
                +    children.sort_by!(&:first)
                +
                +    children.each do |basename, abspath, ftype|
                +      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                +        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +        next
                +      end
                +
                +      yield basename, abspath, ftype
                +    end
                +  end
                +
                +  #: (String) { (String) -> void } -> void
                +  def walk_up(abspath)
                +    loop do
                +      yield abspath
                +      abspath, basename = File.split(abspath)
                +      break if basename == "/"
                +    end
                +  end
                +
                +  # Encodes the documented conventions.
                +  #
                +  #: (String) -> Symbol?
                +  def supported_ftype?(abspath)
                +    if rb_extension?(abspath)
                +      :file # By convention, we can avoid a syscall here.
                +    elsif dir?(abspath)
                +      :directory
                +    end
                +  end
                +
                +  #: (String) -> bool
                +  def rb_extension?(path)
                +    path.end_with?(".rb")
                +  end
                +
                +  #: (String) -> bool
                +  def dir?(path)
                +    File.directory?(path)
                +  end
                +
                +  #: (String) -> bool
                +  def hidden?(basename)
                +    basename.start_with?(".")
                +  end
                +
                +  private
                +
                +  # Looks for a Ruby file using breadth-first search. This type of search is
                +  # important to list as less directories as possible and return fast in the
                +  # common case in which there are Ruby files in the passed directory.
                +  #
                +  #: (String) -> bool
                +  def has_at_least_one_ruby_file?(dir)
                +    to_visit = [dir]
                +
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |_, abspath, ftype|
                +        return true if :file == ftype
                +        to_visit << abspath
                +      end
                +    end
                +
                +    false
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  #: (String) -> [[String, String, Symbol]]
                +  def relevant_dir_entries(dir)
                +    return enum_for(__method__, dir).to_a unless block_given?
                +
                +    each_ruby_file_or_directory(dir) do |basename, abspath, ftype|
                +      next if @loader.__ignored_path?(abspath)
                +
                +      if :link == ftype
                +        begin
                +          ftype = File.stat(abspath).ftype.to_sym
                +        rescue Errno::ENOENT
                +          warn "ignoring broken symlink #{abspath}"
                +          next
                +        end
                +      end
                +
                +      if :file == ftype
                +        yield basename, abspath, ftype if rb_extension?(basename)
                +      elsif :directory == ftype
                +        # Conceptually, root directories represent a separate project tree.
                +        yield basename, abspath, ftype unless @loader.__root_dir?(abspath)
                +      end
                +    end
                +  end
                +
                +  # Dir.scan is more efficient in common platforms, but it is going to take a
                +  # while for it to be available.
                +  #
                +  # The following compatibility methods have the same semantics but are written
                +  # to favor the performance of the Ruby fallback, which can save syscalls.
                +  #
                +  # In particular, by convention, any directory entry with a .rb extension is
                +  # assumed to be a file or a symlink to a file.
                +  #
                +  # These methods also freeze abspaths because that saves allocations when
                +  # passed later to File methods. See https://github.com/fxn/zeitwerk/pull/125.
                +
                +  if Dir.respond_to?(:scan) # Available in Ruby 4.1.
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.scan(dir) do |basename, ftype|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        elsif :directory == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory
                +        elsif :link == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory if dir?(abspath)
                +        end
                +      end
                +    end
                +  else
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.each_child(dir) do |basename|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        else
                +          abspath = File.join(dir, basename).freeze
                +          if dir?(abspath)
                +            yield basename, abspath, :directory
                +          end
                +        end
                +      end
                +    end
                +  end
                +end
      * Changed:
        README.md
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/README.md	2026-02-20 03:33:18.875928890 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/README.md	2026-02-20 03:33:18.878928896 +0000
                @@ -60,0 +61 @@
                +    - [Autoloaded Constants](#autoloaded-constants)
                @@ -1265,0 +1267,12 @@
                +<a id="markdown-autoloaded-constants" name="autoloaded-constants"></a>
                +#### Autoloaded Constants
                +
                +Zeitwerk does not keep track of autoloaded constants to minimize its memory footprint, but you can collect them with `on_load` if you will:
                +
                +```ruby
                +autoloaded_cpaths = []
                +loader.on_load do |cpath, _value, _abspath|
                +  autoloaded_cpaths << cpath
                +end
                +```
                +
                @@ -1408,0 +1422,6 @@
                +```
                +
                +That also accepts a line number:
                +
                +```
                +bin/test test/lib/zeitwerk/test_eager_load.rb:52
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:18.876928892 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:18.879928898 +0000
                @@ -45 +45 @@
                -      ls(@root_dir) do |basename, abspath, ftype|
                +      @fs.ls(@root_dir) do |basename, abspath, ftype|
                @@ -50 +50 @@
                -        cname = inflector.camelize(basename_without_ext, abspath).to_sym
                +        cname = cname_for(basename_without_ext, abspath)
        lib/zeitwerk/loader.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/loader.rb	2026-02-20 03:33:18.876928892 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-02-20 03:33:18.880928900 +0000
                @@ -11,0 +12 @@
                +    require_relative "loader/file_system"
                @@ -21,3 +21,0 @@
                -    MUTEX = Mutex.new #: Mutex
                -    private_constant :MUTEX
                -
                @@ -117,0 +116 @@
                +      @fs              = FileSystem.new(self)
                @@ -174 +173 @@
                -            unloaded_files.add(abspath) if ruby?(abspath)
                +            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -192 +191 @@
                -          unloaded_files.add(abspath) if ruby?(abspath)
                +          unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -202 +201 @@
                -          #   https://github.com/Shopify/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                @@ -258 +257 @@
                -          ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir) do |basename, abspath, ftype|
                @@ -261 +260 @@
                -              result[abspath] = prefix + inflector.camelize(basename, abspath)
                +              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266 +265 @@
                -                queue << [abspath, prefix + inflector.camelize(basename, abspath)]
                +                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                @@ -282 +281,3 @@
                -      return unless dir?(abspath) || ruby?(abspath)
                +      ftype = @fs.supported_ftype?(abspath)
                +      return unless ftype
                +
                @@ -287 +288 @@
                -      if ruby?(abspath)
                +      if :file == ftype
                @@ -289 +290 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -299 +300 @@
                -      walk_up(walk_up_from) do |dir|
                +      @fs.walk_up(walk_up_from) do |dir|
                @@ -304 +305 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -365,0 +367,10 @@
                +    #: { () -> String } -> void
                +    internal def log
                +      return unless logger
                +
                +      message = yield
                +      method_name = logger.respond_to?(:debug) ? :debug : :call
                +      logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                +    end
                +
                +
                @@ -467 +478 @@
                -      ls(dir) do |basename, abspath, ftype|
                +      @fs.ls(dir) do |basename, abspath, ftype|
                @@ -486 +497 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -507 +518 @@
                -        log("the namespace #{cref} already exists, descending into #{subdir}") if logger
                +        log { "the namespace #{cref} already exists, descending into #{subdir}" }
                @@ -516 +527 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -518 +529 @@
                -          log("file #{file} is ignored because #{autoload_path} has precedence") if logger
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                @@ -524 +535 @@
                -        log("file #{file} is ignored because #{cref} is already defined") if logger
                +        log { "file #{file} is ignored because #{cref} is already defined" }
                @@ -538 +549 @@
                -      log("earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}") if logger
                +      log { "earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}" }
                @@ -551,2 +562,2 @@
                -        if ruby?(abspath)
                -          log("autoload set for #{cref}, to be loaded from #{abspath}")
                +        if @fs.rb_extension?(abspath)
                +          log { "autoload set for #{cref}, to be loaded from #{abspath}" }
                @@ -554 +565 @@
                -          log("autoload set for #{cref}, to be autovivified from #{abspath}")
                +          log { "autoload set for #{cref}, to be autovivified from #{abspath}" }
                @@ -598,22 +609,6 @@
                -    private def raise_if_conflicting_directory(dir)
                -      MUTEX.synchronize do
                -        Registry.loaders.each do |loader|
                -          next if loader == self
                -
                -          loader.__roots.each_key do |root_dir|
                -            # Conflicting directories are rare, optimize for the common case.
                -            next if !dir.start_with?(root_dir) && !root_dir.start_with?(dir)
                -
                -            dir_slash = dir + "/"
                -            root_dir_slash = root_dir + "/"
                -            next if !dir_slash.start_with?(root_dir_slash) && !root_dir_slash.start_with?(dir_slash)
                -
                -            next if ignores?(root_dir)
                -            break if loader.__ignores?(dir)
                -
                -            require "pp" # Needed to have pretty_inspect available.
                -            raise Error,
                -              "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{dir}," \
                -              " which is already managed by\n\n#{loader.pretty_inspect}\n"
                -          end
                -        end
                +    private def raise_if_conflicting_root_dir(root_dir)
                +      if loader = Registry.conflicting_root_dir?(self, root_dir)
                +        require "pp" # Needed to have pretty_inspect available.
                +        raise Error,
                +          "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{root_dir}," \
                +          " which is already managed by\n\n#{loader.pretty_inspect}\n"
                @@ -633 +628 @@
                -      log("autoload for #{cref} removed") if logger
                +      log { "autoload for #{cref} removed" }
                @@ -645 +640 @@
                -      log("#{cref} unloaded") if logger
                +      log { "#{cref} unloaded" }
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:18.876928892 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:18.880928900 +0000
                @@ -15 +15 @@
                -      log("constant #{cref} loaded from file #{file}") if logger
                +      log { "constant #{cref} loaded from file #{file}" }
                @@ -20 +20 @@
                -      log(msg) if logger
                +      log { msg }
                @@ -55 +55 @@
                -        log("module #{cref} autovivified from directory #{dir}") if logger
                +        log { "module #{cref} autovivified from directory #{dir}" }
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:18.876928892 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:18.880928900 +0000
                @@ -119,2 +119,2 @@
                -    if dir?(abspath)
                -      raise_if_conflicting_directory(abspath)
                +    if @fs.dir?(abspath)
                +      raise_if_conflicting_root_dir(abspath)
                @@ -293 +293 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -295 +295 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
                @@ -302 +302 @@
                -  private def ignored_path?(abspath)
                +  internal def ignored_path?(abspath)
                @@ -309 +309 @@
                -      !dir?(root_dir) || ignored_path?(root_dir)
                +      !@fs.dir?(root_dir) || ignored_path?(root_dir)
                @@ -314 +314 @@
                -  private def root_dir?(dir)
                +  internal def root_dir?(dir)
                @@ -323 +323 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -325 +325 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:18.876928892 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:18.880928900 +0000
                @@ -14 +14 @@
                -      log("eager load start") if logger
                +      log { "eager load start" }
                @@ -27 +27 @@
                -      log("eager load end") if logger
                +      log { "eager load end" }
                @@ -37 +37 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless @fs.dir?(abspath)
                @@ -39 +39 @@
                -    cnames = []
                +    paths = []
                @@ -42 +42 @@
                -    walk_up(abspath) do |dir|
                +    @fs.walk_up(abspath) do |dir|
                @@ -49 +49 @@
                -      return if hidden?(basename)
                +      return if @fs.hidden?(basename)
                @@ -51,3 +51 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -61 +59,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -120 +119 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if !@fs.rb_extension?(abspath)
                @@ -123,4 +122,2 @@
                -    basename = File.basename(abspath, ".rb")
                -    raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                -
                -    base_cname = inflector.camelize(basename, abspath).to_sym
                +    file_basename = File.basename(abspath, ".rb")
                +    raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(file_basename)
                @@ -129 +126 @@
                -    cnames = []
                +    paths = []
                @@ -131 +128 @@
                -    walk_up(File.dirname(abspath)) do |dir|
                +    @fs.walk_up(File.dirname(abspath)) do |dir|
                @@ -137 +134 @@
                -      raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                +      raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(basename)
                @@ -139,3 +136 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -145,0 +141,2 @@
                +    base_cname = cname_for(file_basename, abspath)
                +
                @@ -147 +144,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -164 +162 @@
                -    log("eager load directory #{dir} start") if logger
                +    log { "eager load directory #{dir} start" }
                @@ -168 +166 @@
                -      ls(current_dir) do |basename, abspath, ftype|
                +      @fs.ls(current_dir) do |basename, abspath, ftype|
                @@ -179 +177 @@
                -            cname = inflector.camelize(basename, abspath).to_sym
                +            cname = cname_for(basename, abspath)
                @@ -186 +184 @@
                -    log("eager load directory #{dir} end") if logger
                +    log { "eager load directory #{dir} end" }
                @@ -211 +209 @@
                -        ls(dir) do |basename, abspath, ftype|
                +        @fs.ls(dir) do |basename, abspath, ftype|
                @@ -216 +214 @@
                -          elsif segment == inflector.camelize(basename, abspath)
                +          elsif segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:18.877928894 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:18.880928900 +0000
                @@ -4,99 +3,0 @@
                -  # --- Logging -----------------------------------------------------------------------------------
                -
                -  #: (to_s() -> String) -> void
                -  private def log(message)
                -    method_name = logger.respond_to?(:debug) ? :debug : :call
                -    logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                -  end
                -
                -  # --- Files and directories ---------------------------------------------------------------------
                -
                -  #: (String) { (String, String, Symbol) -> void } -> void
                -  private def ls(dir)
                -    children = Dir.children(dir)
                -
                -    # The order in which a directory is listed depends on the file system.
                -    #
                -    # Since client code may run in different platforms, it seems convenient to
                -    # order directory entries. This provides consistent eager loading across
                -    # platforms, for example.
                -    children.sort!
                -
                -    children.each do |basename|
                -      next if hidden?(basename)
                -
                -      abspath = File.join(dir, basename)
                -      next if ignored_path?(abspath)
                -
                -      if dir?(abspath)
                -        next if roots.key?(abspath)
                -
                -        if !has_at_least_one_ruby_file?(abspath)
                -          log("directory #{abspath} is ignored because it has no Ruby files") if logger
                -          next
                -        end
                -
                -        ftype = :directory
                -      else
                -        next unless ruby?(abspath)
                -        ftype = :file
                -      end
                -
                -      # We freeze abspath because that saves allocations when passed later to
                -      # File methods. See #125.
                -      yield basename, abspath.freeze, ftype
                -    end
                -  end
                -
                -  # Looks for a Ruby file using breadth-first search. This type of search is
                -  # important to list as less directories as possible and return fast in the
                -  # common case in which there are Ruby files.
                -  #
                -  #: (String) -> bool
                -  private def has_at_least_one_ruby_file?(dir)
                -    to_visit = [dir]
                -
                -    while (dir = to_visit.shift)
                -      Dir.each_child(dir) do |basename|
                -        next if hidden?(basename)
                -
                -        abspath = File.join(dir, basename)
                -        next if ignored_path?(abspath)
                -
                -        if dir?(abspath)
                -          to_visit << abspath unless roots.key?(abspath)
                -        else
                -          return true if ruby?(abspath)
                -        end
                -      end
                -    end
                -
                -    false
                -  end
                -
                -  #: (String) -> bool
                -  private def ruby?(path)
                -    path.end_with?(".rb")
                -  end
                -
                -  #: (String) -> bool
                -  private def dir?(path)
                -    File.directory?(path)
                -  end
                -
                -  #: (String) -> bool
                -  private def hidden?(basename)
                -    basename.start_with?(".")
                -  end
                -
                -  #: (String) { (String) -> void } -> void
                -  private def walk_up(abspath)
                -    loop do
                -      yield abspath
                -      abspath, basename = File.split(abspath)
                -      break if basename == "/"
                -    end
                -  end
                -
                -  # --- Inflection --------------------------------------------------------------------------------
                -
                @@ -127 +28 @@
                -      path_type = ruby?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
        lib/zeitwerk/real_mod_name.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:18.877928894 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:18.880928900 +0000
                @@ -10 +10 @@
                -  # We need this indirection becasue the `name` method can be overridden, and
                +  # We need this indirection because the `name` method can be overridden, and
        lib/zeitwerk/registry.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/registry.rb	2026-02-20 03:33:18.877928894 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-02-20 03:33:18.880928900 +0000
                @@ -46,0 +47,25 @@
                +      #: (Zeitwerk::Loader, String) -> Zeitwerk::Loader?
                +      def conflicting_root_dir?(loader, new_root_dir)
                +        @mutex.synchronize do
                +          loaders.each do |existing_loader|
                +            next if existing_loader == loader
                +
                +            existing_loader.__roots.each_key do |existing_root_dir|
                +              # Conflicting directories are rare, optimize for the common case.
                +              next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir)
                +
                +              new_root_dir_slash = new_root_dir + "/"
                +              existing_root_dir_slash = existing_root_dir + "/"
                +              next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash)
                +
                +              next if loader.__ignores?(existing_root_dir)
                +              break if existing_loader.__ignores?(new_root_dir)
                +
                +              return existing_loader
                +            end
                +          end
                +
                +          nil
                +        end
                +      end
                +
                @@ -61,0 +87 @@
                +    @mutex                    = Mutex.new
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:18.877928894 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:18.881928902 +0000
                @@ -9,2 +9,2 @@
                -    def each(&block)
                -      @loaders.each(&block)
                +    def each(&)
                +      @loaders.each(&)
        lib/zeitwerk/version.rb
                --- /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.4/lib/zeitwerk/version.rb	2026-02-20 03:33:18.877928894 +0000
                +++ /tmp/d20260220-425-sxh2jq/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-02-20 03:33:18.881928902 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.4"
                +  VERSION = "2.7.5"

@github-actions
Copy link
Contributor

gem compare --diff zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/20260220-438-r2xjj4	2026-02-20 03:33:19.204922098 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-02-20 03:33:19.203922101 +0000
                @@ -0,0 +1,165 @@
                +# frozen_string_literal: true
                +
                +# This private class encapsulates interactions with the file system.
                +#
                +# It is used to list directories and check file types, and it encodes the
                +# conventions documented in the README.
                +#
                +# @private
                +class Zeitwerk::Loader::FileSystem # :nodoc:
                +  #: (Zeitwerk::Loader) -> void
                +  def initialize(loader)
                +    @loader = loader
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  def ls(dir)
                +    children = relevant_dir_entries(dir)
                +
                +    # The order in which a directory is listed depends on the file system.
                +    #
                +    # Since client code may run on different platforms, it seems convenient to
                +    # sort directory entries. This provides more deterministic behavior, with
                +    # consistent eager loading in particular.
                +    children.sort_by!(&:first)
                +
                +    children.each do |basename, abspath, ftype|
                +      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                +        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +        next
                +      end
                +
                +      yield basename, abspath, ftype
                +    end
                +  end
                +
                +  #: (String) { (String) -> void } -> void
                +  def walk_up(abspath)
                +    loop do
                +      yield abspath
                +      abspath, basename = File.split(abspath)
                +      break if basename == "/"
                +    end
                +  end
                +
                +  # Encodes the documented conventions.
                +  #
                +  #: (String) -> Symbol?
                +  def supported_ftype?(abspath)
                +    if rb_extension?(abspath)
                +      :file # By convention, we can avoid a syscall here.
                +    elsif dir?(abspath)
                +      :directory
                +    end
                +  end
                +
                +  #: (String) -> bool
                +  def rb_extension?(path)
                +    path.end_with?(".rb")
                +  end
                +
                +  #: (String) -> bool
                +  def dir?(path)
                +    File.directory?(path)
                +  end
                +
                +  #: (String) -> bool
                +  def hidden?(basename)
                +    basename.start_with?(".")
                +  end
                +
                +  private
                +
                +  # Looks for a Ruby file using breadth-first search. This type of search is
                +  # important to list as less directories as possible and return fast in the
                +  # common case in which there are Ruby files in the passed directory.
                +  #
                +  #: (String) -> bool
                +  def has_at_least_one_ruby_file?(dir)
                +    to_visit = [dir]
                +
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |_, abspath, ftype|
                +        return true if :file == ftype
                +        to_visit << abspath
                +      end
                +    end
                +
                +    false
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  #: (String) -> [[String, String, Symbol]]
                +  def relevant_dir_entries(dir)
                +    return enum_for(__method__, dir).to_a unless block_given?
                +
                +    each_ruby_file_or_directory(dir) do |basename, abspath, ftype|
                +      next if @loader.__ignored_path?(abspath)
                +
                +      if :link == ftype
                +        begin
                +          ftype = File.stat(abspath).ftype.to_sym
                +        rescue Errno::ENOENT
                +          warn "ignoring broken symlink #{abspath}"
                +          next
                +        end
                +      end
                +
                +      if :file == ftype
                +        yield basename, abspath, ftype if rb_extension?(basename)
                +      elsif :directory == ftype
                +        # Conceptually, root directories represent a separate project tree.
                +        yield basename, abspath, ftype unless @loader.__root_dir?(abspath)
                +      end
                +    end
                +  end
                +
                +  # Dir.scan is more efficient in common platforms, but it is going to take a
                +  # while for it to be available.
                +  #
                +  # The following compatibility methods have the same semantics but are written
                +  # to favor the performance of the Ruby fallback, which can save syscalls.
                +  #
                +  # In particular, by convention, any directory entry with a .rb extension is
                +  # assumed to be a file or a symlink to a file.
                +  #
                +  # These methods also freeze abspaths because that saves allocations when
                +  # passed later to File methods. See https://github.com/fxn/zeitwerk/pull/125.
                +
                +  if Dir.respond_to?(:scan) # Available in Ruby 4.1.
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.scan(dir) do |basename, ftype|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        elsif :directory == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory
                +        elsif :link == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory if dir?(abspath)
                +        end
                +      end
                +    end
                +  else
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.each_child(dir) do |basename|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        else
                +          abspath = File.join(dir, basename).freeze
                +          if dir?(abspath)
                +            yield basename, abspath, :directory
                +          end
                +        end
                +      end
                +    end
                +  end
                +end
      * Changed:
        README.md
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/README.md	2026-02-20 03:33:19.197922114 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/README.md	2026-02-20 03:33:19.201922105 +0000
                @@ -60,0 +61 @@
                +    - [Autoloaded Constants](#autoloaded-constants)
                @@ -1265,0 +1267,12 @@
                +<a id="markdown-autoloaded-constants" name="autoloaded-constants"></a>
                +#### Autoloaded Constants
                +
                +Zeitwerk does not keep track of autoloaded constants to minimize its memory footprint, but you can collect them with `on_load` if you will:
                +
                +```ruby
                +autoloaded_cpaths = []
                +loader.on_load do |cpath, _value, _abspath|
                +  autoloaded_cpaths << cpath
                +end
                +```
                +
                @@ -1408,0 +1422,6 @@
                +```
                +
                +That also accepts a line number:
                +
                +```
                +bin/test test/lib/zeitwerk/test_eager_load.rb:52
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:19.198922112 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:19.202922103 +0000
                @@ -45 +45 @@
                -      ls(@root_dir) do |basename, abspath, ftype|
                +      @fs.ls(@root_dir) do |basename, abspath, ftype|
                @@ -50 +50 @@
                -        cname = inflector.camelize(basename_without_ext, abspath).to_sym
                +        cname = cname_for(basename_without_ext, abspath)
        lib/zeitwerk/loader.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/loader.rb	2026-02-20 03:33:19.199922109 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-02-20 03:33:19.202922103 +0000
                @@ -11,0 +12 @@
                +    require_relative "loader/file_system"
                @@ -21,3 +21,0 @@
                -    MUTEX = Mutex.new #: Mutex
                -    private_constant :MUTEX
                -
                @@ -117,0 +116 @@
                +      @fs              = FileSystem.new(self)
                @@ -174 +173 @@
                -            unloaded_files.add(abspath) if ruby?(abspath)
                +            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -192 +191 @@
                -          unloaded_files.add(abspath) if ruby?(abspath)
                +          unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -202 +201 @@
                -          #   https://github.com/Shopify/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                @@ -258 +257 @@
                -          ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir) do |basename, abspath, ftype|
                @@ -261 +260 @@
                -              result[abspath] = prefix + inflector.camelize(basename, abspath)
                +              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266 +265 @@
                -                queue << [abspath, prefix + inflector.camelize(basename, abspath)]
                +                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                @@ -282 +281,3 @@
                -      return unless dir?(abspath) || ruby?(abspath)
                +      ftype = @fs.supported_ftype?(abspath)
                +      return unless ftype
                +
                @@ -287 +288 @@
                -      if ruby?(abspath)
                +      if :file == ftype
                @@ -289 +290 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -299 +300 @@
                -      walk_up(walk_up_from) do |dir|
                +      @fs.walk_up(walk_up_from) do |dir|
                @@ -304 +305 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -365,0 +367,10 @@
                +    #: { () -> String } -> void
                +    internal def log
                +      return unless logger
                +
                +      message = yield
                +      method_name = logger.respond_to?(:debug) ? :debug : :call
                +      logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                +    end
                +
                +
                @@ -467 +478 @@
                -      ls(dir) do |basename, abspath, ftype|
                +      @fs.ls(dir) do |basename, abspath, ftype|
                @@ -486 +497 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -507 +518 @@
                -        log("the namespace #{cref} already exists, descending into #{subdir}") if logger
                +        log { "the namespace #{cref} already exists, descending into #{subdir}" }
                @@ -516 +527 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -518 +529 @@
                -          log("file #{file} is ignored because #{autoload_path} has precedence") if logger
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                @@ -524 +535 @@
                -        log("file #{file} is ignored because #{cref} is already defined") if logger
                +        log { "file #{file} is ignored because #{cref} is already defined" }
                @@ -538 +549 @@
                -      log("earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}") if logger
                +      log { "earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}" }
                @@ -551,2 +562,2 @@
                -        if ruby?(abspath)
                -          log("autoload set for #{cref}, to be loaded from #{abspath}")
                +        if @fs.rb_extension?(abspath)
                +          log { "autoload set for #{cref}, to be loaded from #{abspath}" }
                @@ -554 +565 @@
                -          log("autoload set for #{cref}, to be autovivified from #{abspath}")
                +          log { "autoload set for #{cref}, to be autovivified from #{abspath}" }
                @@ -598,22 +609,6 @@
                -    private def raise_if_conflicting_directory(dir)
                -      MUTEX.synchronize do
                -        Registry.loaders.each do |loader|
                -          next if loader == self
                -
                -          loader.__roots.each_key do |root_dir|
                -            # Conflicting directories are rare, optimize for the common case.
                -            next if !dir.start_with?(root_dir) && !root_dir.start_with?(dir)
                -
                -            dir_slash = dir + "/"
                -            root_dir_slash = root_dir + "/"
                -            next if !dir_slash.start_with?(root_dir_slash) && !root_dir_slash.start_with?(dir_slash)
                -
                -            next if ignores?(root_dir)
                -            break if loader.__ignores?(dir)
                -
                -            require "pp" # Needed to have pretty_inspect available.
                -            raise Error,
                -              "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{dir}," \
                -              " which is already managed by\n\n#{loader.pretty_inspect}\n"
                -          end
                -        end
                +    private def raise_if_conflicting_root_dir(root_dir)
                +      if loader = Registry.conflicting_root_dir?(self, root_dir)
                +        require "pp" # Needed to have pretty_inspect available.
                +        raise Error,
                +          "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{root_dir}," \
                +          " which is already managed by\n\n#{loader.pretty_inspect}\n"
                @@ -633 +628 @@
                -      log("autoload for #{cref} removed") if logger
                +      log { "autoload for #{cref} removed" }
                @@ -645 +640 @@
                -      log("#{cref} unloaded") if logger
                +      log { "#{cref} unloaded" }
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:19.199922109 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:19.202922103 +0000
                @@ -15 +15 @@
                -      log("constant #{cref} loaded from file #{file}") if logger
                +      log { "constant #{cref} loaded from file #{file}" }
                @@ -20 +20 @@
                -      log(msg) if logger
                +      log { msg }
                @@ -55 +55 @@
                -        log("module #{cref} autovivified from directory #{dir}") if logger
                +        log { "module #{cref} autovivified from directory #{dir}" }
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:19.199922109 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:19.202922103 +0000
                @@ -119,2 +119,2 @@
                -    if dir?(abspath)
                -      raise_if_conflicting_directory(abspath)
                +    if @fs.dir?(abspath)
                +      raise_if_conflicting_root_dir(abspath)
                @@ -293 +293 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -295 +295 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
                @@ -302 +302 @@
                -  private def ignored_path?(abspath)
                +  internal def ignored_path?(abspath)
                @@ -309 +309 @@
                -      !dir?(root_dir) || ignored_path?(root_dir)
                +      !@fs.dir?(root_dir) || ignored_path?(root_dir)
                @@ -314 +314 @@
                -  private def root_dir?(dir)
                +  internal def root_dir?(dir)
                @@ -323 +323 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -325 +325 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:19.199922109 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:19.203922101 +0000
                @@ -14 +14 @@
                -      log("eager load start") if logger
                +      log { "eager load start" }
                @@ -27 +27 @@
                -      log("eager load end") if logger
                +      log { "eager load end" }
                @@ -37 +37 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless @fs.dir?(abspath)
                @@ -39 +39 @@
                -    cnames = []
                +    paths = []
                @@ -42 +42 @@
                -    walk_up(abspath) do |dir|
                +    @fs.walk_up(abspath) do |dir|
                @@ -49 +49 @@
                -      return if hidden?(basename)
                +      return if @fs.hidden?(basename)
                @@ -51,3 +51 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -61 +59,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -120 +119 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if !@fs.rb_extension?(abspath)
                @@ -123,4 +122,2 @@
                -    basename = File.basename(abspath, ".rb")
                -    raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                -
                -    base_cname = inflector.camelize(basename, abspath).to_sym
                +    file_basename = File.basename(abspath, ".rb")
                +    raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(file_basename)
                @@ -129 +126 @@
                -    cnames = []
                +    paths = []
                @@ -131 +128 @@
                -    walk_up(File.dirname(abspath)) do |dir|
                +    @fs.walk_up(File.dirname(abspath)) do |dir|
                @@ -137 +134 @@
                -      raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                +      raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(basename)
                @@ -139,3 +136 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -145,0 +141,2 @@
                +    base_cname = cname_for(file_basename, abspath)
                +
                @@ -147 +144,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -164 +162 @@
                -    log("eager load directory #{dir} start") if logger
                +    log { "eager load directory #{dir} start" }
                @@ -168 +166 @@
                -      ls(current_dir) do |basename, abspath, ftype|
                +      @fs.ls(current_dir) do |basename, abspath, ftype|
                @@ -179 +177 @@
                -            cname = inflector.camelize(basename, abspath).to_sym
                +            cname = cname_for(basename, abspath)
                @@ -186 +184 @@
                -    log("eager load directory #{dir} end") if logger
                +    log { "eager load directory #{dir} end" }
                @@ -211 +209 @@
                -        ls(dir) do |basename, abspath, ftype|
                +        @fs.ls(dir) do |basename, abspath, ftype|
                @@ -216 +214 @@
                -          elsif segment == inflector.camelize(basename, abspath)
                +          elsif segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:19.199922109 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:19.203922101 +0000
                @@ -4,99 +3,0 @@
                -  # --- Logging -----------------------------------------------------------------------------------
                -
                -  #: (to_s() -> String) -> void
                -  private def log(message)
                -    method_name = logger.respond_to?(:debug) ? :debug : :call
                -    logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                -  end
                -
                -  # --- Files and directories ---------------------------------------------------------------------
                -
                -  #: (String) { (String, String, Symbol) -> void } -> void
                -  private def ls(dir)
                -    children = Dir.children(dir)
                -
                -    # The order in which a directory is listed depends on the file system.
                -    #
                -    # Since client code may run in different platforms, it seems convenient to
                -    # order directory entries. This provides consistent eager loading across
                -    # platforms, for example.
                -    children.sort!
                -
                -    children.each do |basename|
                -      next if hidden?(basename)
                -
                -      abspath = File.join(dir, basename)
                -      next if ignored_path?(abspath)
                -
                -      if dir?(abspath)
                -        next if roots.key?(abspath)
                -
                -        if !has_at_least_one_ruby_file?(abspath)
                -          log("directory #{abspath} is ignored because it has no Ruby files") if logger
                -          next
                -        end
                -
                -        ftype = :directory
                -      else
                -        next unless ruby?(abspath)
                -        ftype = :file
                -      end
                -
                -      # We freeze abspath because that saves allocations when passed later to
                -      # File methods. See #125.
                -      yield basename, abspath.freeze, ftype
                -    end
                -  end
                -
                -  # Looks for a Ruby file using breadth-first search. This type of search is
                -  # important to list as less directories as possible and return fast in the
                -  # common case in which there are Ruby files.
                -  #
                -  #: (String) -> bool
                -  private def has_at_least_one_ruby_file?(dir)
                -    to_visit = [dir]
                -
                -    while (dir = to_visit.shift)
                -      Dir.each_child(dir) do |basename|
                -        next if hidden?(basename)
                -
                -        abspath = File.join(dir, basename)
                -        next if ignored_path?(abspath)
                -
                -        if dir?(abspath)
                -          to_visit << abspath unless roots.key?(abspath)
                -        else
                -          return true if ruby?(abspath)
                -        end
                -      end
                -    end
                -
                -    false
                -  end
                -
                -  #: (String) -> bool
                -  private def ruby?(path)
                -    path.end_with?(".rb")
                -  end
                -
                -  #: (String) -> bool
                -  private def dir?(path)
                -    File.directory?(path)
                -  end
                -
                -  #: (String) -> bool
                -  private def hidden?(basename)
                -    basename.start_with?(".")
                -  end
                -
                -  #: (String) { (String) -> void } -> void
                -  private def walk_up(abspath)
                -    loop do
                -      yield abspath
                -      abspath, basename = File.split(abspath)
                -      break if basename == "/"
                -    end
                -  end
                -
                -  # --- Inflection --------------------------------------------------------------------------------
                -
                @@ -127 +28 @@
                -      path_type = ruby?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
        lib/zeitwerk/real_mod_name.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:19.199922109 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:19.203922101 +0000
                @@ -10 +10 @@
                -  # We need this indirection becasue the `name` method can be overridden, and
                +  # We need this indirection because the `name` method can be overridden, and
        lib/zeitwerk/registry.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/registry.rb	2026-02-20 03:33:19.199922109 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-02-20 03:33:19.203922101 +0000
                @@ -46,0 +47,25 @@
                +      #: (Zeitwerk::Loader, String) -> Zeitwerk::Loader?
                +      def conflicting_root_dir?(loader, new_root_dir)
                +        @mutex.synchronize do
                +          loaders.each do |existing_loader|
                +            next if existing_loader == loader
                +
                +            existing_loader.__roots.each_key do |existing_root_dir|
                +              # Conflicting directories are rare, optimize for the common case.
                +              next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir)
                +
                +              new_root_dir_slash = new_root_dir + "/"
                +              existing_root_dir_slash = existing_root_dir + "/"
                +              next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash)
                +
                +              next if loader.__ignores?(existing_root_dir)
                +              break if existing_loader.__ignores?(new_root_dir)
                +
                +              return existing_loader
                +            end
                +          end
                +
                +          nil
                +        end
                +      end
                +
                @@ -61,0 +87 @@
                +    @mutex                    = Mutex.new
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:19.200922107 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:19.203922101 +0000
                @@ -9,2 +9,2 @@
                -    def each(&block)
                -      @loaders.each(&block)
                +    def each(&)
                +      @loaders.each(&)
        lib/zeitwerk/version.rb
                --- /tmp/d20260220-438-2syz3k/zeitwerk-2.7.4/lib/zeitwerk/version.rb	2026-02-20 03:33:19.200922107 +0000
                +++ /tmp/d20260220-438-2syz3k/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-02-20 03:33:19.203922101 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.4"
                +  VERSION = "2.7.5"

@github-actions
Copy link
Contributor

gem compare zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT date:
    2.7.4: 2025-12-16 00:00:00 UTC
    2.7.5: 1980-01-02 00:00:00 UTC
  DIFFERENT rubygems_version:
    2.7.4: 3.4.19
    2.7.5: 4.0.3
  DIFFERENT version:
    2.7.4: 2.7.4
    2.7.5: 2.7.5
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
            lib/zeitwerk/loader/file_system.rb +165/-0
      * Changed:
            README.md +19/-0
            lib/zeitwerk/gem_loader.rb +2/-2
            lib/zeitwerk/loader.rb +43/-48
            lib/zeitwerk/loader/callbacks.rb +3/-3
            lib/zeitwerk/loader/config.rb +9/-9
            lib/zeitwerk/loader/eager_load.rb +26/-28
            lib/zeitwerk/loader/helpers.rb +1/-100
            lib/zeitwerk/real_mod_name.rb +1/-1
            lib/zeitwerk/registry.rb +26/-0
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

@github-actions
Copy link
Contributor

gem compare --diff zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/20260220-412-b2n0kt	2026-02-20 03:33:37.396972916 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-02-20 03:33:37.395972906 +0000
                @@ -0,0 +1,165 @@
                +# frozen_string_literal: true
                +
                +# This private class encapsulates interactions with the file system.
                +#
                +# It is used to list directories and check file types, and it encodes the
                +# conventions documented in the README.
                +#
                +# @private
                +class Zeitwerk::Loader::FileSystem # :nodoc:
                +  #: (Zeitwerk::Loader) -> void
                +  def initialize(loader)
                +    @loader = loader
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  def ls(dir)
                +    children = relevant_dir_entries(dir)
                +
                +    # The order in which a directory is listed depends on the file system.
                +    #
                +    # Since client code may run on different platforms, it seems convenient to
                +    # sort directory entries. This provides more deterministic behavior, with
                +    # consistent eager loading in particular.
                +    children.sort_by!(&:first)
                +
                +    children.each do |basename, abspath, ftype|
                +      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                +        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +        next
                +      end
                +
                +      yield basename, abspath, ftype
                +    end
                +  end
                +
                +  #: (String) { (String) -> void } -> void
                +  def walk_up(abspath)
                +    loop do
                +      yield abspath
                +      abspath, basename = File.split(abspath)
                +      break if basename == "/"
                +    end
                +  end
                +
                +  # Encodes the documented conventions.
                +  #
                +  #: (String) -> Symbol?
                +  def supported_ftype?(abspath)
                +    if rb_extension?(abspath)
                +      :file # By convention, we can avoid a syscall here.
                +    elsif dir?(abspath)
                +      :directory
                +    end
                +  end
                +
                +  #: (String) -> bool
                +  def rb_extension?(path)
                +    path.end_with?(".rb")
                +  end
                +
                +  #: (String) -> bool
                +  def dir?(path)
                +    File.directory?(path)
                +  end
                +
                +  #: (String) -> bool
                +  def hidden?(basename)
                +    basename.start_with?(".")
                +  end
                +
                +  private
                +
                +  # Looks for a Ruby file using breadth-first search. This type of search is
                +  # important to list as less directories as possible and return fast in the
                +  # common case in which there are Ruby files in the passed directory.
                +  #
                +  #: (String) -> bool
                +  def has_at_least_one_ruby_file?(dir)
                +    to_visit = [dir]
                +
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |_, abspath, ftype|
                +        return true if :file == ftype
                +        to_visit << abspath
                +      end
                +    end
                +
                +    false
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  #: (String) -> [[String, String, Symbol]]
                +  def relevant_dir_entries(dir)
                +    return enum_for(__method__, dir).to_a unless block_given?
                +
                +    each_ruby_file_or_directory(dir) do |basename, abspath, ftype|
                +      next if @loader.__ignored_path?(abspath)
                +
                +      if :link == ftype
                +        begin
                +          ftype = File.stat(abspath).ftype.to_sym
                +        rescue Errno::ENOENT
                +          warn "ignoring broken symlink #{abspath}"
                +          next
                +        end
                +      end
                +
                +      if :file == ftype
                +        yield basename, abspath, ftype if rb_extension?(basename)
                +      elsif :directory == ftype
                +        # Conceptually, root directories represent a separate project tree.
                +        yield basename, abspath, ftype unless @loader.__root_dir?(abspath)
                +      end
                +    end
                +  end
                +
                +  # Dir.scan is more efficient in common platforms, but it is going to take a
                +  # while for it to be available.
                +  #
                +  # The following compatibility methods have the same semantics but are written
                +  # to favor the performance of the Ruby fallback, which can save syscalls.
                +  #
                +  # In particular, by convention, any directory entry with a .rb extension is
                +  # assumed to be a file or a symlink to a file.
                +  #
                +  # These methods also freeze abspaths because that saves allocations when
                +  # passed later to File methods. See https://github.com/fxn/zeitwerk/pull/125.
                +
                +  if Dir.respond_to?(:scan) # Available in Ruby 4.1.
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.scan(dir) do |basename, ftype|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        elsif :directory == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory
                +        elsif :link == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory if dir?(abspath)
                +        end
                +      end
                +    end
                +  else
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.each_child(dir) do |basename|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        else
                +          abspath = File.join(dir, basename).freeze
                +          if dir?(abspath)
                +            yield basename, abspath, :directory
                +          end
                +        end
                +      end
                +    end
                +  end
                +end
      * Changed:
        README.md
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/README.md	2026-02-20 03:33:37.389972846 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/README.md	2026-02-20 03:33:37.393972886 +0000
                @@ -60,0 +61 @@
                +    - [Autoloaded Constants](#autoloaded-constants)
                @@ -1265,0 +1267,12 @@
                +<a id="markdown-autoloaded-constants" name="autoloaded-constants"></a>
                +#### Autoloaded Constants
                +
                +Zeitwerk does not keep track of autoloaded constants to minimize its memory footprint, but you can collect them with `on_load` if you will:
                +
                +```ruby
                +autoloaded_cpaths = []
                +loader.on_load do |cpath, _value, _abspath|
                +  autoloaded_cpaths << cpath
                +end
                +```
                +
                @@ -1408,0 +1422,6 @@
                +```
                +
                +That also accepts a line number:
                +
                +```
                +bin/test test/lib/zeitwerk/test_eager_load.rb:52
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:37.390972856 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:37.394972896 +0000
                @@ -45 +45 @@
                -      ls(@root_dir) do |basename, abspath, ftype|
                +      @fs.ls(@root_dir) do |basename, abspath, ftype|
                @@ -50 +50 @@
                -        cname = inflector.camelize(basename_without_ext, abspath).to_sym
                +        cname = cname_for(basename_without_ext, abspath)
        lib/zeitwerk/loader.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/loader.rb	2026-02-20 03:33:37.390972856 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-02-20 03:33:37.394972896 +0000
                @@ -11,0 +12 @@
                +    require_relative "loader/file_system"
                @@ -21,3 +21,0 @@
                -    MUTEX = Mutex.new #: Mutex
                -    private_constant :MUTEX
                -
                @@ -117,0 +116 @@
                +      @fs              = FileSystem.new(self)
                @@ -174 +173 @@
                -            unloaded_files.add(abspath) if ruby?(abspath)
                +            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -192 +191 @@
                -          unloaded_files.add(abspath) if ruby?(abspath)
                +          unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -202 +201 @@
                -          #   https://github.com/Shopify/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                @@ -258 +257 @@
                -          ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir) do |basename, abspath, ftype|
                @@ -261 +260 @@
                -              result[abspath] = prefix + inflector.camelize(basename, abspath)
                +              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266 +265 @@
                -                queue << [abspath, prefix + inflector.camelize(basename, abspath)]
                +                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                @@ -282 +281,3 @@
                -      return unless dir?(abspath) || ruby?(abspath)
                +      ftype = @fs.supported_ftype?(abspath)
                +      return unless ftype
                +
                @@ -287 +288 @@
                -      if ruby?(abspath)
                +      if :file == ftype
                @@ -289 +290 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -299 +300 @@
                -      walk_up(walk_up_from) do |dir|
                +      @fs.walk_up(walk_up_from) do |dir|
                @@ -304 +305 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -365,0 +367,10 @@
                +    #: { () -> String } -> void
                +    internal def log
                +      return unless logger
                +
                +      message = yield
                +      method_name = logger.respond_to?(:debug) ? :debug : :call
                +      logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                +    end
                +
                +
                @@ -467 +478 @@
                -      ls(dir) do |basename, abspath, ftype|
                +      @fs.ls(dir) do |basename, abspath, ftype|
                @@ -486 +497 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -507 +518 @@
                -        log("the namespace #{cref} already exists, descending into #{subdir}") if logger
                +        log { "the namespace #{cref} already exists, descending into #{subdir}" }
                @@ -516 +527 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -518 +529 @@
                -          log("file #{file} is ignored because #{autoload_path} has precedence") if logger
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                @@ -524 +535 @@
                -        log("file #{file} is ignored because #{cref} is already defined") if logger
                +        log { "file #{file} is ignored because #{cref} is already defined" }
                @@ -538 +549 @@
                -      log("earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}") if logger
                +      log { "earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}" }
                @@ -551,2 +562,2 @@
                -        if ruby?(abspath)
                -          log("autoload set for #{cref}, to be loaded from #{abspath}")
                +        if @fs.rb_extension?(abspath)
                +          log { "autoload set for #{cref}, to be loaded from #{abspath}" }
                @@ -554 +565 @@
                -          log("autoload set for #{cref}, to be autovivified from #{abspath}")
                +          log { "autoload set for #{cref}, to be autovivified from #{abspath}" }
                @@ -598,22 +609,6 @@
                -    private def raise_if_conflicting_directory(dir)
                -      MUTEX.synchronize do
                -        Registry.loaders.each do |loader|
                -          next if loader == self
                -
                -          loader.__roots.each_key do |root_dir|
                -            # Conflicting directories are rare, optimize for the common case.
                -            next if !dir.start_with?(root_dir) && !root_dir.start_with?(dir)
                -
                -            dir_slash = dir + "/"
                -            root_dir_slash = root_dir + "/"
                -            next if !dir_slash.start_with?(root_dir_slash) && !root_dir_slash.start_with?(dir_slash)
                -
                -            next if ignores?(root_dir)
                -            break if loader.__ignores?(dir)
                -
                -            require "pp" # Needed to have pretty_inspect available.
                -            raise Error,
                -              "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{dir}," \
                -              " which is already managed by\n\n#{loader.pretty_inspect}\n"
                -          end
                -        end
                +    private def raise_if_conflicting_root_dir(root_dir)
                +      if loader = Registry.conflicting_root_dir?(self, root_dir)
                +        require "pp" # Needed to have pretty_inspect available.
                +        raise Error,
                +          "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{root_dir}," \
                +          " which is already managed by\n\n#{loader.pretty_inspect}\n"
                @@ -633 +628 @@
                -      log("autoload for #{cref} removed") if logger
                +      log { "autoload for #{cref} removed" }
                @@ -645 +640 @@
                -      log("#{cref} unloaded") if logger
                +      log { "#{cref} unloaded" }
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:37.390972856 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:37.394972896 +0000
                @@ -15 +15 @@
                -      log("constant #{cref} loaded from file #{file}") if logger
                +      log { "constant #{cref} loaded from file #{file}" }
                @@ -20 +20 @@
                -      log(msg) if logger
                +      log { msg }
                @@ -55 +55 @@
                -        log("module #{cref} autovivified from directory #{dir}") if logger
                +        log { "module #{cref} autovivified from directory #{dir}" }
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:37.390972856 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:37.394972896 +0000
                @@ -119,2 +119,2 @@
                -    if dir?(abspath)
                -      raise_if_conflicting_directory(abspath)
                +    if @fs.dir?(abspath)
                +      raise_if_conflicting_root_dir(abspath)
                @@ -293 +293 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -295 +295 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
                @@ -302 +302 @@
                -  private def ignored_path?(abspath)
                +  internal def ignored_path?(abspath)
                @@ -309 +309 @@
                -      !dir?(root_dir) || ignored_path?(root_dir)
                +      !@fs.dir?(root_dir) || ignored_path?(root_dir)
                @@ -314 +314 @@
                -  private def root_dir?(dir)
                +  internal def root_dir?(dir)
                @@ -323 +323 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -325 +325 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:37.390972856 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:37.394972896 +0000
                @@ -14 +14 @@
                -      log("eager load start") if logger
                +      log { "eager load start" }
                @@ -27 +27 @@
                -      log("eager load end") if logger
                +      log { "eager load end" }
                @@ -37 +37 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless @fs.dir?(abspath)
                @@ -39 +39 @@
                -    cnames = []
                +    paths = []
                @@ -42 +42 @@
                -    walk_up(abspath) do |dir|
                +    @fs.walk_up(abspath) do |dir|
                @@ -49 +49 @@
                -      return if hidden?(basename)
                +      return if @fs.hidden?(basename)
                @@ -51,3 +51 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -61 +59,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -120 +119 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if !@fs.rb_extension?(abspath)
                @@ -123,4 +122,2 @@
                -    basename = File.basename(abspath, ".rb")
                -    raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                -
                -    base_cname = inflector.camelize(basename, abspath).to_sym
                +    file_basename = File.basename(abspath, ".rb")
                +    raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(file_basename)
                @@ -129 +126 @@
                -    cnames = []
                +    paths = []
                @@ -131 +128 @@
                -    walk_up(File.dirname(abspath)) do |dir|
                +    @fs.walk_up(File.dirname(abspath)) do |dir|
                @@ -137 +134 @@
                -      raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                +      raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(basename)
                @@ -139,3 +136 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -145,0 +141,2 @@
                +    base_cname = cname_for(file_basename, abspath)
                +
                @@ -147 +144,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -164 +162 @@
                -    log("eager load directory #{dir} start") if logger
                +    log { "eager load directory #{dir} start" }
                @@ -168 +166 @@
                -      ls(current_dir) do |basename, abspath, ftype|
                +      @fs.ls(current_dir) do |basename, abspath, ftype|
                @@ -179 +177 @@
                -            cname = inflector.camelize(basename, abspath).to_sym
                +            cname = cname_for(basename, abspath)
                @@ -186 +184 @@
                -    log("eager load directory #{dir} end") if logger
                +    log { "eager load directory #{dir} end" }
                @@ -211 +209 @@
                -        ls(dir) do |basename, abspath, ftype|
                +        @fs.ls(dir) do |basename, abspath, ftype|
                @@ -216 +214 @@
                -          elsif segment == inflector.camelize(basename, abspath)
                +          elsif segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:37.390972856 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:37.395972906 +0000
                @@ -4,99 +3,0 @@
                -  # --- Logging -----------------------------------------------------------------------------------
                -
                -  #: (to_s() -> String) -> void
                -  private def log(message)
                -    method_name = logger.respond_to?(:debug) ? :debug : :call
                -    logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                -  end
                -
                -  # --- Files and directories ---------------------------------------------------------------------
                -
                -  #: (String) { (String, String, Symbol) -> void } -> void
                -  private def ls(dir)
                -    children = Dir.children(dir)
                -
                -    # The order in which a directory is listed depends on the file system.
                -    #
                -    # Since client code may run in different platforms, it seems convenient to
                -    # order directory entries. This provides consistent eager loading across
                -    # platforms, for example.
                -    children.sort!
                -
                -    children.each do |basename|
                -      next if hidden?(basename)
                -
                -      abspath = File.join(dir, basename)
                -      next if ignored_path?(abspath)
                -
                -      if dir?(abspath)
                -        next if roots.key?(abspath)
                -
                -        if !has_at_least_one_ruby_file?(abspath)
                -          log("directory #{abspath} is ignored because it has no Ruby files") if logger
                -          next
                -        end
                -
                -        ftype = :directory
                -      else
                -        next unless ruby?(abspath)
                -        ftype = :file
                -      end
                -
                -      # We freeze abspath because that saves allocations when passed later to
                -      # File methods. See #125.
                -      yield basename, abspath.freeze, ftype
                -    end
                -  end
                -
                -  # Looks for a Ruby file using breadth-first search. This type of search is
                -  # important to list as less directories as possible and return fast in the
                -  # common case in which there are Ruby files.
                -  #
                -  #: (String) -> bool
                -  private def has_at_least_one_ruby_file?(dir)
                -    to_visit = [dir]
                -
                -    while (dir = to_visit.shift)
                -      Dir.each_child(dir) do |basename|
                -        next if hidden?(basename)
                -
                -        abspath = File.join(dir, basename)
                -        next if ignored_path?(abspath)
                -
                -        if dir?(abspath)
                -          to_visit << abspath unless roots.key?(abspath)
                -        else
                -          return true if ruby?(abspath)
                -        end
                -      end
                -    end
                -
                -    false
                -  end
                -
                -  #: (String) -> bool
                -  private def ruby?(path)
                -    path.end_with?(".rb")
                -  end
                -
                -  #: (String) -> bool
                -  private def dir?(path)
                -    File.directory?(path)
                -  end
                -
                -  #: (String) -> bool
                -  private def hidden?(basename)
                -    basename.start_with?(".")
                -  end
                -
                -  #: (String) { (String) -> void } -> void
                -  private def walk_up(abspath)
                -    loop do
                -      yield abspath
                -      abspath, basename = File.split(abspath)
                -      break if basename == "/"
                -    end
                -  end
                -
                -  # --- Inflection --------------------------------------------------------------------------------
                -
                @@ -127 +28 @@
                -      path_type = ruby?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
        lib/zeitwerk/real_mod_name.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:37.391972866 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:37.395972906 +0000
                @@ -10 +10 @@
                -  # We need this indirection becasue the `name` method can be overridden, and
                +  # We need this indirection because the `name` method can be overridden, and
        lib/zeitwerk/registry.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/registry.rb	2026-02-20 03:33:37.391972866 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-02-20 03:33:37.395972906 +0000
                @@ -46,0 +47,25 @@
                +      #: (Zeitwerk::Loader, String) -> Zeitwerk::Loader?
                +      def conflicting_root_dir?(loader, new_root_dir)
                +        @mutex.synchronize do
                +          loaders.each do |existing_loader|
                +            next if existing_loader == loader
                +
                +            existing_loader.__roots.each_key do |existing_root_dir|
                +              # Conflicting directories are rare, optimize for the common case.
                +              next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir)
                +
                +              new_root_dir_slash = new_root_dir + "/"
                +              existing_root_dir_slash = existing_root_dir + "/"
                +              next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash)
                +
                +              next if loader.__ignores?(existing_root_dir)
                +              break if existing_loader.__ignores?(new_root_dir)
                +
                +              return existing_loader
                +            end
                +          end
                +
                +          nil
                +        end
                +      end
                +
                @@ -61,0 +87 @@
                +    @mutex                    = Mutex.new
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:37.391972866 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:37.395972906 +0000
                @@ -9,2 +9,2 @@
                -    def each(&block)
                -      @loaders.each(&block)
                +    def each(&)
                +      @loaders.each(&)
        lib/zeitwerk/version.rb
                --- /tmp/d20260220-412-tjs18b/zeitwerk-2.7.4/lib/zeitwerk/version.rb	2026-02-20 03:33:37.391972866 +0000
                +++ /tmp/d20260220-412-tjs18b/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-02-20 03:33:37.395972906 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.4"
                +  VERSION = "2.7.5"

@github-actions
Copy link
Contributor

gem compare --diff zeitwerk 2.7.4 2.7.5

Compared versions: ["2.7.4", "2.7.5"]
  DIFFERENT files:
    2.7.4->2.7.5:
      * Added:
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/20260220-410-n0kuna	2026-02-20 03:33:38.328375284 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-02-20 03:33:38.327375285 +0000
                @@ -0,0 +1,165 @@
                +# frozen_string_literal: true
                +
                +# This private class encapsulates interactions with the file system.
                +#
                +# It is used to list directories and check file types, and it encodes the
                +# conventions documented in the README.
                +#
                +# @private
                +class Zeitwerk::Loader::FileSystem # :nodoc:
                +  #: (Zeitwerk::Loader) -> void
                +  def initialize(loader)
                +    @loader = loader
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  def ls(dir)
                +    children = relevant_dir_entries(dir)
                +
                +    # The order in which a directory is listed depends on the file system.
                +    #
                +    # Since client code may run on different platforms, it seems convenient to
                +    # sort directory entries. This provides more deterministic behavior, with
                +    # consistent eager loading in particular.
                +    children.sort_by!(&:first)
                +
                +    children.each do |basename, abspath, ftype|
                +      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                +        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +        next
                +      end
                +
                +      yield basename, abspath, ftype
                +    end
                +  end
                +
                +  #: (String) { (String) -> void } -> void
                +  def walk_up(abspath)
                +    loop do
                +      yield abspath
                +      abspath, basename = File.split(abspath)
                +      break if basename == "/"
                +    end
                +  end
                +
                +  # Encodes the documented conventions.
                +  #
                +  #: (String) -> Symbol?
                +  def supported_ftype?(abspath)
                +    if rb_extension?(abspath)
                +      :file # By convention, we can avoid a syscall here.
                +    elsif dir?(abspath)
                +      :directory
                +    end
                +  end
                +
                +  #: (String) -> bool
                +  def rb_extension?(path)
                +    path.end_with?(".rb")
                +  end
                +
                +  #: (String) -> bool
                +  def dir?(path)
                +    File.directory?(path)
                +  end
                +
                +  #: (String) -> bool
                +  def hidden?(basename)
                +    basename.start_with?(".")
                +  end
                +
                +  private
                +
                +  # Looks for a Ruby file using breadth-first search. This type of search is
                +  # important to list as less directories as possible and return fast in the
                +  # common case in which there are Ruby files in the passed directory.
                +  #
                +  #: (String) -> bool
                +  def has_at_least_one_ruby_file?(dir)
                +    to_visit = [dir]
                +
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |_, abspath, ftype|
                +        return true if :file == ftype
                +        to_visit << abspath
                +      end
                +    end
                +
                +    false
                +  end
                +
                +  #: (String) { (String, String, Symbol) -> void } -> void
                +  #: (String) -> [[String, String, Symbol]]
                +  def relevant_dir_entries(dir)
                +    return enum_for(__method__, dir).to_a unless block_given?
                +
                +    each_ruby_file_or_directory(dir) do |basename, abspath, ftype|
                +      next if @loader.__ignored_path?(abspath)
                +
                +      if :link == ftype
                +        begin
                +          ftype = File.stat(abspath).ftype.to_sym
                +        rescue Errno::ENOENT
                +          warn "ignoring broken symlink #{abspath}"
                +          next
                +        end
                +      end
                +
                +      if :file == ftype
                +        yield basename, abspath, ftype if rb_extension?(basename)
                +      elsif :directory == ftype
                +        # Conceptually, root directories represent a separate project tree.
                +        yield basename, abspath, ftype unless @loader.__root_dir?(abspath)
                +      end
                +    end
                +  end
                +
                +  # Dir.scan is more efficient in common platforms, but it is going to take a
                +  # while for it to be available.
                +  #
                +  # The following compatibility methods have the same semantics but are written
                +  # to favor the performance of the Ruby fallback, which can save syscalls.
                +  #
                +  # In particular, by convention, any directory entry with a .rb extension is
                +  # assumed to be a file or a symlink to a file.
                +  #
                +  # These methods also freeze abspaths because that saves allocations when
                +  # passed later to File methods. See https://github.com/fxn/zeitwerk/pull/125.
                +
                +  if Dir.respond_to?(:scan) # Available in Ruby 4.1.
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.scan(dir) do |basename, ftype|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        elsif :directory == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory
                +        elsif :link == ftype
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :directory if dir?(abspath)
                +        end
                +      end
                +    end
                +  else
                +    #: (String) { (String, String, Symbol) -> void } -> void
                +    def each_ruby_file_or_directory(dir)
                +      Dir.each_child(dir) do |basename|
                +        next if hidden?(basename)
                +
                +        if rb_extension?(basename)
                +          abspath = File.join(dir, basename).freeze
                +          yield basename, abspath, :file # By convention.
                +        else
                +          abspath = File.join(dir, basename).freeze
                +          if dir?(abspath)
                +            yield basename, abspath, :directory
                +          end
                +        end
                +      end
                +    end
                +  end
                +end
      * Changed:
        README.md
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/README.md	2026-02-20 03:33:38.321375287 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/README.md	2026-02-20 03:33:38.324375286 +0000
                @@ -60,0 +61 @@
                +    - [Autoloaded Constants](#autoloaded-constants)
                @@ -1265,0 +1267,12 @@
                +<a id="markdown-autoloaded-constants" name="autoloaded-constants"></a>
                +#### Autoloaded Constants
                +
                +Zeitwerk does not keep track of autoloaded constants to minimize its memory footprint, but you can collect them with `on_load` if you will:
                +
                +```ruby
                +autoloaded_cpaths = []
                +loader.on_load do |cpath, _value, _abspath|
                +  autoloaded_cpaths << cpath
                +end
                +```
                +
                @@ -1408,0 +1422,6 @@
                +```
                +
                +That also accepts a line number:
                +
                +```
                +bin/test test/lib/zeitwerk/test_eager_load.rb:52
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:38.322375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-02-20 03:33:38.326375285 +0000
                @@ -45 +45 @@
                -      ls(@root_dir) do |basename, abspath, ftype|
                +      @fs.ls(@root_dir) do |basename, abspath, ftype|
                @@ -50 +50 @@
                -        cname = inflector.camelize(basename_without_ext, abspath).to_sym
                +        cname = cname_for(basename_without_ext, abspath)
        lib/zeitwerk/loader.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/loader.rb	2026-02-20 03:33:38.323375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-02-20 03:33:38.326375285 +0000
                @@ -11,0 +12 @@
                +    require_relative "loader/file_system"
                @@ -21,3 +21,0 @@
                -    MUTEX = Mutex.new #: Mutex
                -    private_constant :MUTEX
                -
                @@ -117,0 +116 @@
                +      @fs              = FileSystem.new(self)
                @@ -174 +173 @@
                -            unloaded_files.add(abspath) if ruby?(abspath)
                +            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -192 +191 @@
                -          unloaded_files.add(abspath) if ruby?(abspath)
                +          unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                @@ -202 +201 @@
                -          #   https://github.com/Shopify/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                @@ -258 +257 @@
                -          ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir) do |basename, abspath, ftype|
                @@ -261 +260 @@
                -              result[abspath] = prefix + inflector.camelize(basename, abspath)
                +              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266 +265 @@
                -                queue << [abspath, prefix + inflector.camelize(basename, abspath)]
                +                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                @@ -282 +281,3 @@
                -      return unless dir?(abspath) || ruby?(abspath)
                +      ftype = @fs.supported_ftype?(abspath)
                +      return unless ftype
                +
                @@ -287 +288 @@
                -      if ruby?(abspath)
                +      if :file == ftype
                @@ -289 +290 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -299 +300 @@
                -      walk_up(walk_up_from) do |dir|
                +      @fs.walk_up(walk_up_from) do |dir|
                @@ -304 +305 @@
                -        return if hidden?(basename)
                +        return if @fs.hidden?(basename)
                @@ -365,0 +367,10 @@
                +    #: { () -> String } -> void
                +    internal def log
                +      return unless logger
                +
                +      message = yield
                +      method_name = logger.respond_to?(:debug) ? :debug : :call
                +      logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                +    end
                +
                +
                @@ -467 +478 @@
                -      ls(dir) do |basename, abspath, ftype|
                +      @fs.ls(dir) do |basename, abspath, ftype|
                @@ -486 +497 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -507 +518 @@
                -        log("the namespace #{cref} already exists, descending into #{subdir}") if logger
                +        log { "the namespace #{cref} already exists, descending into #{subdir}" }
                @@ -516 +527 @@
                -        if ruby?(autoload_path)
                +        if @fs.rb_extension?(autoload_path)
                @@ -518 +529 @@
                -          log("file #{file} is ignored because #{autoload_path} has precedence") if logger
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                @@ -524 +535 @@
                -        log("file #{file} is ignored because #{cref} is already defined") if logger
                +        log { "file #{file} is ignored because #{cref} is already defined" }
                @@ -538 +549 @@
                -      log("earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}") if logger
                +      log { "earlier autoload for #{cref} discarded, it is actually an explicit namespace defined in #{file}" }
                @@ -551,2 +562,2 @@
                -        if ruby?(abspath)
                -          log("autoload set for #{cref}, to be loaded from #{abspath}")
                +        if @fs.rb_extension?(abspath)
                +          log { "autoload set for #{cref}, to be loaded from #{abspath}" }
                @@ -554 +565 @@
                -          log("autoload set for #{cref}, to be autovivified from #{abspath}")
                +          log { "autoload set for #{cref}, to be autovivified from #{abspath}" }
                @@ -598,22 +609,6 @@
                -    private def raise_if_conflicting_directory(dir)
                -      MUTEX.synchronize do
                -        Registry.loaders.each do |loader|
                -          next if loader == self
                -
                -          loader.__roots.each_key do |root_dir|
                -            # Conflicting directories are rare, optimize for the common case.
                -            next if !dir.start_with?(root_dir) && !root_dir.start_with?(dir)
                -
                -            dir_slash = dir + "/"
                -            root_dir_slash = root_dir + "/"
                -            next if !dir_slash.start_with?(root_dir_slash) && !root_dir_slash.start_with?(dir_slash)
                -
                -            next if ignores?(root_dir)
                -            break if loader.__ignores?(dir)
                -
                -            require "pp" # Needed to have pretty_inspect available.
                -            raise Error,
                -              "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{dir}," \
                -              " which is already managed by\n\n#{loader.pretty_inspect}\n"
                -          end
                -        end
                +    private def raise_if_conflicting_root_dir(root_dir)
                +      if loader = Registry.conflicting_root_dir?(self, root_dir)
                +        require "pp" # Needed to have pretty_inspect available.
                +        raise Error,
                +          "loader\n\n#{pretty_inspect}\n\nwants to manage directory #{root_dir}," \
                +          " which is already managed by\n\n#{loader.pretty_inspect}\n"
                @@ -633 +628 @@
                -      log("autoload for #{cref} removed") if logger
                +      log { "autoload for #{cref} removed" }
                @@ -645 +640 @@
                -      log("#{cref} unloaded") if logger
                +      log { "#{cref} unloaded" }
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:38.323375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-02-20 03:33:38.326375285 +0000
                @@ -15 +15 @@
                -      log("constant #{cref} loaded from file #{file}") if logger
                +      log { "constant #{cref} loaded from file #{file}" }
                @@ -20 +20 @@
                -      log(msg) if logger
                +      log { msg }
                @@ -55 +55 @@
                -        log("module #{cref} autovivified from directory #{dir}") if logger
                +        log { "module #{cref} autovivified from directory #{dir}" }
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:38.323375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-02-20 03:33:38.326375285 +0000
                @@ -119,2 +119,2 @@
                -    if dir?(abspath)
                -      raise_if_conflicting_directory(abspath)
                +    if @fs.dir?(abspath)
                +      raise_if_conflicting_root_dir(abspath)
                @@ -293 +293 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -295 +295 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
                @@ -302 +302 @@
                -  private def ignored_path?(abspath)
                +  internal def ignored_path?(abspath)
                @@ -309 +309 @@
                -      !dir?(root_dir) || ignored_path?(root_dir)
                +      !@fs.dir?(root_dir) || ignored_path?(root_dir)
                @@ -314 +314 @@
                -  private def root_dir?(dir)
                +  internal def root_dir?(dir)
                @@ -323 +323 @@
                -    walk_up(abspath) do |path|
                +    @fs.walk_up(abspath) do |path|
                @@ -325 +325 @@
                -      return false if roots.key?(path)
                +      return false if root_dir?(path)
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:38.323375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-02-20 03:33:38.327375285 +0000
                @@ -14 +14 @@
                -      log("eager load start") if logger
                +      log { "eager load start" }
                @@ -27 +27 @@
                -      log("eager load end") if logger
                +      log { "eager load end" }
                @@ -37 +37 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless dir?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a directory") unless @fs.dir?(abspath)
                @@ -39 +39 @@
                -    cnames = []
                +    paths = []
                @@ -42 +42 @@
                -    walk_up(abspath) do |dir|
                +    @fs.walk_up(abspath) do |dir|
                @@ -49 +49 @@
                -      return if hidden?(basename)
                +      return if @fs.hidden?(basename)
                @@ -51,3 +51 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -61 +59,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -120 +119 @@
                -    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if dir?(abspath) || !ruby?(abspath)
                +    raise Zeitwerk::Error.new("#{abspath} is not a Ruby file") if !@fs.rb_extension?(abspath)
                @@ -123,4 +122,2 @@
                -    basename = File.basename(abspath, ".rb")
                -    raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                -
                -    base_cname = inflector.camelize(basename, abspath).to_sym
                +    file_basename = File.basename(abspath, ".rb")
                +    raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(file_basename)
                @@ -129 +126 @@
                -    cnames = []
                +    paths = []
                @@ -131 +128 @@
                -    walk_up(File.dirname(abspath)) do |dir|
                +    @fs.walk_up(File.dirname(abspath)) do |dir|
                @@ -137 +134 @@
                -      raise Zeitwerk::Error.new("#{abspath} is ignored") if hidden?(basename)
                +      raise Zeitwerk::Error.new("#{abspath} is ignored") if @fs.hidden?(basename)
                @@ -139,3 +136 @@
                -      unless collapse?(dir)
                -        cnames << inflector.camelize(basename, dir).to_sym
                -      end
                +      paths << [basename, dir] unless collapse?(dir)
                @@ -145,0 +141,2 @@
                +    base_cname = cname_for(file_basename, abspath)
                +
                @@ -147 +144,2 @@
                -    cnames.reverse_each do |cname|
                +    paths.reverse_each do |basename, dir|
                +      cname = cname_for(basename, dir)
                @@ -164 +162 @@
                -    log("eager load directory #{dir} start") if logger
                +    log { "eager load directory #{dir} start" }
                @@ -168 +166 @@
                -      ls(current_dir) do |basename, abspath, ftype|
                +      @fs.ls(current_dir) do |basename, abspath, ftype|
                @@ -179 +177 @@
                -            cname = inflector.camelize(basename, abspath).to_sym
                +            cname = cname_for(basename, abspath)
                @@ -186 +184 @@
                -    log("eager load directory #{dir} end") if logger
                +    log { "eager load directory #{dir} end" }
                @@ -211 +209 @@
                -        ls(dir) do |basename, abspath, ftype|
                +        @fs.ls(dir) do |basename, abspath, ftype|
                @@ -216 +214 @@
                -          elsif segment == inflector.camelize(basename, abspath)
                +          elsif segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:38.323375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-02-20 03:33:38.327375285 +0000
                @@ -4,99 +3,0 @@
                -  # --- Logging -----------------------------------------------------------------------------------
                -
                -  #: (to_s() -> String) -> void
                -  private def log(message)
                -    method_name = logger.respond_to?(:debug) ? :debug : :call
                -    logger.send(method_name, "Zeitwerk@#{tag}: #{message}")
                -  end
                -
                -  # --- Files and directories ---------------------------------------------------------------------
                -
                -  #: (String) { (String, String, Symbol) -> void } -> void
                -  private def ls(dir)
                -    children = Dir.children(dir)
                -
                -    # The order in which a directory is listed depends on the file system.
                -    #
                -    # Since client code may run in different platforms, it seems convenient to
                -    # order directory entries. This provides consistent eager loading across
                -    # platforms, for example.
                -    children.sort!
                -
                -    children.each do |basename|
                -      next if hidden?(basename)
                -
                -      abspath = File.join(dir, basename)
                -      next if ignored_path?(abspath)
                -
                -      if dir?(abspath)
                -        next if roots.key?(abspath)
                -
                -        if !has_at_least_one_ruby_file?(abspath)
                -          log("directory #{abspath} is ignored because it has no Ruby files") if logger
                -          next
                -        end
                -
                -        ftype = :directory
                -      else
                -        next unless ruby?(abspath)
                -        ftype = :file
                -      end
                -
                -      # We freeze abspath because that saves allocations when passed later to
                -      # File methods. See #125.
                -      yield basename, abspath.freeze, ftype
                -    end
                -  end
                -
                -  # Looks for a Ruby file using breadth-first search. This type of search is
                -  # important to list as less directories as possible and return fast in the
                -  # common case in which there are Ruby files.
                -  #
                -  #: (String) -> bool
                -  private def has_at_least_one_ruby_file?(dir)
                -    to_visit = [dir]
                -
                -    while (dir = to_visit.shift)
                -      Dir.each_child(dir) do |basename|
                -        next if hidden?(basename)
                -
                -        abspath = File.join(dir, basename)
                -        next if ignored_path?(abspath)
                -
                -        if dir?(abspath)
                -          to_visit << abspath unless roots.key?(abspath)
                -        else
                -          return true if ruby?(abspath)
                -        end
                -      end
                -    end
                -
                -    false
                -  end
                -
                -  #: (String) -> bool
                -  private def ruby?(path)
                -    path.end_with?(".rb")
                -  end
                -
                -  #: (String) -> bool
                -  private def dir?(path)
                -    File.directory?(path)
                -  end
                -
                -  #: (String) -> bool
                -  private def hidden?(basename)
                -    basename.start_with?(".")
                -  end
                -
                -  #: (String) { (String) -> void } -> void
                -  private def walk_up(abspath)
                -    loop do
                -      yield abspath
                -      abspath, basename = File.split(abspath)
                -      break if basename == "/"
                -    end
                -  end
                -
                -  # --- Inflection --------------------------------------------------------------------------------
                -
                @@ -127 +28 @@
                -      path_type = ruby?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
        lib/zeitwerk/real_mod_name.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:38.323375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/real_mod_name.rb	2026-02-20 03:33:38.327375285 +0000
                @@ -10 +10 @@
                -  # We need this indirection becasue the `name` method can be overridden, and
                +  # We need this indirection because the `name` method can be overridden, and
        lib/zeitwerk/registry.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/registry.rb	2026-02-20 03:33:38.323375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-02-20 03:33:38.327375285 +0000
                @@ -46,0 +47,25 @@
                +      #: (Zeitwerk::Loader, String) -> Zeitwerk::Loader?
                +      def conflicting_root_dir?(loader, new_root_dir)
                +        @mutex.synchronize do
                +          loaders.each do |existing_loader|
                +            next if existing_loader == loader
                +
                +            existing_loader.__roots.each_key do |existing_root_dir|
                +              # Conflicting directories are rare, optimize for the common case.
                +              next if !new_root_dir.start_with?(existing_root_dir) && !existing_root_dir.start_with?(new_root_dir)
                +
                +              new_root_dir_slash = new_root_dir + "/"
                +              existing_root_dir_slash = existing_root_dir + "/"
                +              next if !new_root_dir_slash.start_with?(existing_root_dir_slash) && !existing_root_dir_slash.start_with?(new_root_dir_slash)
                +
                +              next if loader.__ignores?(existing_root_dir)
                +              break if existing_loader.__ignores?(new_root_dir)
                +
                +              return existing_loader
                +            end
                +          end
                +
                +          nil
                +        end
                +      end
                +
                @@ -61,0 +87 @@
                +    @mutex                    = Mutex.new
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:38.324375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-02-20 03:33:38.327375285 +0000
                @@ -9,2 +9,2 @@
                -    def each(&block)
                -      @loaders.each(&block)
                +    def each(&)
                +      @loaders.each(&)
        lib/zeitwerk/version.rb
                --- /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.4/lib/zeitwerk/version.rb	2026-02-20 03:33:38.324375286 +0000
                +++ /tmp/d20260220-410-3n1fwj/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-02-20 03:33:38.327375285 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.4"
                +  VERSION = "2.7.5"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies ruby Pull requests that update Ruby code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants