Skip to content

Bump zeitwerk from 2.7.5 to 2.8.1#988

Closed
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/bundler/zeitwerk-2.8.1
Closed

Bump zeitwerk from 2.7.5 to 2.8.1#988
dependabot[bot] wants to merge 1 commit into
mainfrom
dependabot/bundler/zeitwerk-2.8.1

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github May 20, 2026

Copy link
Copy Markdown
Contributor

Bumps zeitwerk from 2.7.5 to 2.8.1.

Changelog

Sourced from zeitwerk's changelog.

2.8.1 (19 May 2026)

  • Replace anonymous block parameters with regular named ones.

    Ruby 3.3.0 has a bug: it does not parse anonymous block parameters, which were introduced in Ruby 3.1.

    While this is a Ruby bug and people could upgrade to 3.3.1, I prefer users just do not hit this. At the end of the day, it is cosmetic.

2.8.0 (18 May 2026)

  • Adds support for namespace files, nsfiles for short.

    If a loader has an nsfile configured (nil by default):

    loader.nsfile = 'ns.rb' # must be set before setup

    explicit namespaces can be defined by such special file inside their directories:

    my_component/ns.rb     # MyComponent
    my_component/widget.rb # MyComponent::Widget
    

    This may be handy for self-contained units for which a my_component.rb file in the parent directory would feel unnatural.

    If an nsfile is set, you can still define explicit namespaces as always. Both styles can coexist in the project. However, it is an error condition to try to define the same namespace using both conventions.

    For further details, please check the documentation for nsfiles.

  • When a file is shadowed because the constant path it maps to already exists, the location of said constant is included in the log message.

Commits

@dependabot dependabot Bot added dependencies ruby Pull requests that update Ruby code labels May 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

4 similar comments
@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor

gem compare zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT version:
    2.7.5: 2.7.5
    2.8.1: 2.8.1
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
            README.md +125/-71
            lib/zeitwerk.rb +13/-13
            lib/zeitwerk/core_ext/kernel.rb +1/-1
            lib/zeitwerk/cref.rb +8/-1
            lib/zeitwerk/cref/map.rb +1/-1
            lib/zeitwerk/error.rb +12/-1
            lib/zeitwerk/gem_inflector.rb +3/-3
            lib/zeitwerk/gem_loader.rb +4/-4
            lib/zeitwerk/inflector.rb +8/-8
            lib/zeitwerk/loader.rb +165/-127
            lib/zeitwerk/loader/callbacks.rb +1/-1
            lib/zeitwerk/loader/config.rb +64/-18
            lib/zeitwerk/loader/eager_load.rb +23/-27
            lib/zeitwerk/loader/file_system.rb +72/-25
            lib/zeitwerk/loader/helpers.rb +2/-2
            lib/zeitwerk/registry.rb +6/-6
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

4 similar comments
@github-actions

Copy link
Copy Markdown
Contributor

gem compare zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT version:
    2.7.5: 2.7.5
    2.8.1: 2.8.1
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
            README.md +125/-71
            lib/zeitwerk.rb +13/-13
            lib/zeitwerk/core_ext/kernel.rb +1/-1
            lib/zeitwerk/cref.rb +8/-1
            lib/zeitwerk/cref/map.rb +1/-1
            lib/zeitwerk/error.rb +12/-1
            lib/zeitwerk/gem_inflector.rb +3/-3
            lib/zeitwerk/gem_loader.rb +4/-4
            lib/zeitwerk/inflector.rb +8/-8
            lib/zeitwerk/loader.rb +165/-127
            lib/zeitwerk/loader/callbacks.rb +1/-1
            lib/zeitwerk/loader/config.rb +64/-18
            lib/zeitwerk/loader/eager_load.rb +23/-27
            lib/zeitwerk/loader/file_system.rb +72/-25
            lib/zeitwerk/loader/helpers.rb +2/-2
            lib/zeitwerk/registry.rb +6/-6
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

@github-actions

Copy link
Copy Markdown
Contributor

gem compare zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT version:
    2.7.5: 2.7.5
    2.8.1: 2.8.1
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
            README.md +125/-71
            lib/zeitwerk.rb +13/-13
            lib/zeitwerk/core_ext/kernel.rb +1/-1
            lib/zeitwerk/cref.rb +8/-1
            lib/zeitwerk/cref/map.rb +1/-1
            lib/zeitwerk/error.rb +12/-1
            lib/zeitwerk/gem_inflector.rb +3/-3
            lib/zeitwerk/gem_loader.rb +4/-4
            lib/zeitwerk/inflector.rb +8/-8
            lib/zeitwerk/loader.rb +165/-127
            lib/zeitwerk/loader/callbacks.rb +1/-1
            lib/zeitwerk/loader/config.rb +64/-18
            lib/zeitwerk/loader/eager_load.rb +23/-27
            lib/zeitwerk/loader/file_system.rb +72/-25
            lib/zeitwerk/loader/helpers.rb +2/-2
            lib/zeitwerk/registry.rb +6/-6
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

@github-actions

Copy link
Copy Markdown
Contributor

gem compare zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT version:
    2.7.5: 2.7.5
    2.8.1: 2.8.1
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
            README.md +125/-71
            lib/zeitwerk.rb +13/-13
            lib/zeitwerk/core_ext/kernel.rb +1/-1
            lib/zeitwerk/cref.rb +8/-1
            lib/zeitwerk/cref/map.rb +1/-1
            lib/zeitwerk/error.rb +12/-1
            lib/zeitwerk/gem_inflector.rb +3/-3
            lib/zeitwerk/gem_loader.rb +4/-4
            lib/zeitwerk/inflector.rb +8/-8
            lib/zeitwerk/loader.rb +165/-127
            lib/zeitwerk/loader/callbacks.rb +1/-1
            lib/zeitwerk/loader/config.rb +64/-18
            lib/zeitwerk/loader/eager_load.rb +23/-27
            lib/zeitwerk/loader/file_system.rb +72/-25
            lib/zeitwerk/loader/helpers.rb +2/-2
            lib/zeitwerk/registry.rb +6/-6
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

@github-actions

Copy link
Copy Markdown
Contributor

gem compare zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT version:
    2.7.5: 2.7.5
    2.8.1: 2.8.1
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
            README.md +125/-71
            lib/zeitwerk.rb +13/-13
            lib/zeitwerk/core_ext/kernel.rb +1/-1
            lib/zeitwerk/cref.rb +8/-1
            lib/zeitwerk/cref/map.rb +1/-1
            lib/zeitwerk/error.rb +12/-1
            lib/zeitwerk/gem_inflector.rb +3/-3
            lib/zeitwerk/gem_loader.rb +4/-4
            lib/zeitwerk/inflector.rb +8/-8
            lib/zeitwerk/loader.rb +165/-127
            lib/zeitwerk/loader/callbacks.rb +1/-1
            lib/zeitwerk/loader/config.rb +64/-18
            lib/zeitwerk/loader/eager_load.rb +23/-27
            lib/zeitwerk/loader/file_system.rb +72/-25
            lib/zeitwerk/loader/helpers.rb +2/-2
            lib/zeitwerk/registry.rb +6/-6
            lib/zeitwerk/registry/loaders.rb +2/-2
            lib/zeitwerk/version.rb +1/-1

@github-actions

Copy link
Copy Markdown
Contributor

gem compare --diff zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
        README.md
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/README.md	2026-05-20 07:03:18.814903165 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/README.md	2026-05-20 07:03:18.817903146 +0000
                @@ -21,0 +22,2 @@
                +    - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
                +    - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
                @@ -101 +103 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -184 +186 @@
                -loader.inflector.inflect("max_retries" => "MAX_RETRIES")
                +loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
                @@ -223,2 +225,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -267 +269,6 @@
                -Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
                +Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
                +
                +<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
                +#### Explicit namespaces defined in ordinary files
                +
                +Let's consider:
                @@ -274 +281 @@
                -There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
                +Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
                @@ -276 +283,3 @@
                -The classes and modules from the namespace are already available in the body of the class or module defining it:
                +In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
                +
                +The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
                @@ -287 +296,42 @@
                -An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
                +<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
                +#### Explicit namespaces defined in nsfiles
                +
                +If the loader has an nsfile configured (defaults to `nil`):
                +
                +```ruby
                +loader.nsfile = 'ns.rb' # must be set before setup
                +```
                +
                +you can alternatively define the explicit namespace inside its directory:
                +
                +```
                +my_component/ns.rb     -> MyComponent
                +my_component/widget.rb -> MyComponent::Widget
                +```
                +
                +This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
                +
                +A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
                +
                +Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
                +
                +```
                +my_component/src/ns.rb            -> MyComponent
                +my_component/src/widget.rb        -> MyComponent::Widget
                +my_component/assets/widget.js
                +my_component/tests/test_widget.rb
                +```
                +
                +Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
                +
                +However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
                +
                +Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
                +
                +Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
                +
                +```ruby
                +loader.nsfile = 'index.rb'
                +```
                +
                +there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
                @@ -375 +425 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -377 +427 @@
                -loader.tag = File.basename(__FILE__, ".rb")
                +loader.tag = File.basename(__FILE__, '.rb')
                @@ -387 +437 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -433 +483 @@
                -gem "net-http-niche_feature"
                +gem 'net-http-niche_feature'
                @@ -446 +496 @@
                -require "net/http/niche_feature"
                +require 'net/http/niche_feature'
                @@ -451,2 +501,2 @@
                -require "net/http"
                -require "zeitwerk"
                +require 'net/http'
                +require 'zeitwerk'
                @@ -467 +517 @@
                -  VERSION = "1.0.0"
                +  VERSION = '1.0.0'
                @@ -489 +539 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -498 +548 @@
                -That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                +That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                @@ -685 +735 @@
                -require "concurrent/atomic/read_write_lock"
                +require 'concurrent/atomic/read_write_lock'
                @@ -730,2 +780,2 @@
                -  "html_parser"   => "HTMLParser",
                -  "mysql_adapter" => "MySQLAdapter"
                +  'html_parser'   => 'HTMLParser',
                +  'mysql_adapter' => 'MySQLAdapter'
                @@ -738,2 +788,2 @@
                -loader.inflector.inflect "html_parser" => "HTMLParser"
                -loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
                +loader.inflector.inflect 'html_parser' => 'HTMLParser'
                +loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
                @@ -745 +795 @@
                -loader.inflector.inflect("xml" => "XML")
                +loader.inflector.inflect('xml' => 'XML')
                @@ -760,2 +810,2 @@
                -  "xml"        => "XML",
                -  "xml_parser" => "XMLParser"
                +  'xml'        => 'XML',
                +  'xml_parser' => 'XMLParser'
                @@ -816 +866 @@
                -      "HTML" + super($1, abspath)
                +      'HTML' + super($1, abspath)
                @@ -847,2 +897,2 @@
                -require "zeitwerk"
                -require_relative "my_gem/inflector"
                +require 'zeitwerk'
                +require_relative 'my_gem/inflector'
                @@ -916,2 +966,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.dev"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.dev'
                @@ -921,2 +971,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.prod"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.prod'
                @@ -966 +1016 @@
                -loader.on_unload("Country") do |klass, _abspath|
                +loader.on_unload('Country') do |klass, _abspath|
                @@ -1051 +1101 @@
                -loader.tag = "grep_me"
                +loader.tag = 'grep_me'
                @@ -1107 +1157 @@
                -require_relative "my_gem/core_ext/kernel"
                +require_relative 'my_gem/core_ext/kernel'
                @@ -1121 +1171 @@
                -require "pg"
                +require 'pg'
                @@ -1159 +1209 @@
                -require "foo"
                +require 'foo'
                @@ -1228,2 +1278,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -1231 +1281 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -1250,2 +1300,2 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.dirs # => ["/foo"]
                +loader.push_dir(Pathname.new('/foo'))
                +loader.dirs # => ['/foo']
                @@ -1258,3 +1308,3 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.push_dir(Pathname.new("/bar"), namespace: Bar)
                -loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
                +loader.push_dir(Pathname.new('/foo'))
                +loader.push_dir(Pathname.new('/bar'), namespace: Bar)
                +loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
                @@ -1287,4 +1337,4 @@
                -loader.cpath_expected_at("app/models")                  # => "Object"
                -loader.cpath_expected_at("app/models/user.rb")          # => "User"
                -loader.cpath_expected_at("app/models/hotel")            # => "Hotel"
                -loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
                +loader.cpath_expected_at('app/models')                  # => 'Object'
                +loader.cpath_expected_at('app/models/user.rb')          # => 'User'
                +loader.cpath_expected_at('app/models/hotel')            # => 'Hotel'
                +loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
                @@ -1296,3 +1346,3 @@
                -loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
                -loader.cpath_expected_at("a/b/collapsed")   # => "A::B", edge case
                -loader.cpath_expected_at("a/b")             # => "A::B"
                +loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
                +loader.cpath_expected_at('a/b/collapsed')   # => 'A::B', edge case
                +loader.cpath_expected_at('a/b')             # => 'A::B'
                @@ -1306 +1356 @@
                -loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
                +loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
                @@ -1312 +1362 @@
                -loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
                +loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
                @@ -1315 +1365,3 @@
                -This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1332 +1383,0 @@
                -lib/my_gem/ignored.rb
                @@ -1335,0 +1387 @@
                +lib/my_gem/ignored.rb
                @@ -1343,9 +1395,9 @@
                -  "/.../lib"                           => "Object",
                -  "/.../lib/my_gem.rb"                 => "MyGem",
                -  "/.../lib/my_gem"                    => "MyGem",
                -  "/.../lib/my_gem/version.rb"         => "MyGem::VERSION",
                -  "/.../lib/my_gem/drivers"            => "MyGem::Drivers",
                -  "/.../lib/my_gem/drivers/unix.rb"    => "MyGem::Drivers::Unix",
                -  "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
                -  "/.../lib/my_gem/collapsed"          => "MyGem",
                -  "/.../lib/my_gem/collapsed/foo.rb"   => "MyGem::Foo"
                +  '/.../lib'                           => 'Object',
                +  '/.../lib/my_gem.rb'                 => 'MyGem',
                +  '/.../lib/my_gem'                    => 'MyGem',
                +  '/.../lib/my_gem/version.rb'         => 'MyGem::VERSION',
                +  '/.../lib/my_gem/drivers'            => 'MyGem::Drivers',
                +  '/.../lib/my_gem/drivers/unix.rb'    => 'MyGem::Drivers::Unix',
                +  '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
                +  '/.../lib/my_gem/collapsed'          => 'MyGem',
                +  '/.../lib/my_gem/collapsed/foo.rb'   => 'MyGem::Foo'
                @@ -1357 +1409 @@
                -The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
                +The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
                @@ -1363 +1415,3 @@
                -This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1434,2 +1488,2 @@
                -test "capitalizes the first letter" do
                -  assert_equal "User", camelize("user")
                +test 'capitalizes the first letter' do
                +  assert_equal 'User', camelize('user')
                @@ -1449 +1503 @@
                -Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
                +Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
        lib/zeitwerk.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk.rb	2026-05-20 07:03:18.814903165 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk.rb	2026-05-20 07:03:18.818903140 +0000
                @@ -4,11 +4,11 @@
                -  require_relative "zeitwerk/real_mod_name"
                -  require_relative "zeitwerk/internal"
                -  require_relative "zeitwerk/cref"
                -  require_relative "zeitwerk/loader"
                -  require_relative "zeitwerk/gem_loader"
                -  require_relative "zeitwerk/registry"
                -  require_relative "zeitwerk/inflector"
                -  require_relative "zeitwerk/gem_inflector"
                -  require_relative "zeitwerk/null_inflector"
                -  require_relative "zeitwerk/error"
                -  require_relative "zeitwerk/version"
                +  require_relative 'zeitwerk/real_mod_name'
                +  require_relative 'zeitwerk/internal'
                +  require_relative 'zeitwerk/cref'
                +  require_relative 'zeitwerk/loader'
                +  require_relative 'zeitwerk/gem_loader'
                +  require_relative 'zeitwerk/registry'
                +  require_relative 'zeitwerk/inflector'
                +  require_relative 'zeitwerk/gem_inflector'
                +  require_relative 'zeitwerk/null_inflector'
                +  require_relative 'zeitwerk/error'
                +  require_relative 'zeitwerk/version'
                @@ -16,2 +16,2 @@
                -  require_relative "zeitwerk/core_ext/kernel"
                -  require_relative "zeitwerk/core_ext/module"
                +  require_relative 'zeitwerk/core_ext/kernel'
                +  require_relative 'zeitwerk/core_ext/module'
        lib/zeitwerk/core_ext/kernel.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:18.814903165 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:18.818903140 +0000
                @@ -25 +25 @@
                -      if path.end_with?(".rb")
                +      if path.end_with?('.rb')
        lib/zeitwerk/cref.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/cref.rb	2026-05-20 07:03:18.814903165 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/cref.rb	2026-05-20 07:03:18.818903140 +0000
                @@ -14 +14 @@
                -  require_relative "cref/map"
                +  require_relative 'cref/map'
                @@ -67,0 +68,7 @@
                +  end
                +
                +  #: () -> String?
                +  def location
                +    if (location = @mod.const_source_location(@cname)) && !location.empty?
                +      location.join(':')
                +    end
        lib/zeitwerk/cref/map.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:18.814903165 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:18.818903140 +0000
                @@ -30 +30 @@
                -#      { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
                +#      { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
        lib/zeitwerk/error.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/error.rb	2026-05-20 07:03:18.814903165 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/error.rb	2026-05-20 07:03:18.818903140 +0000
                @@ -20 +20,12 @@
                -      super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
                +      super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
                +    end
                +  end
                +
                +  class ConflictingNamespaceDefinitionError < Error
                +    #: (String, location: String?, conflicting_file: String) -> void
                +    def initialize(cpath, location:, conflicting_file:)
                +      if location
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
                +      else
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
                +      end
        lib/zeitwerk/gem_inflector.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:18.815903159 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:18.818903140 +0000
                @@ -7 +7 @@
                -      namespace     = File.basename(root_file, ".rb")
                +      namespace     = File.basename(root_file, '.rb')
                @@ -9 +9 @@
                -      @version_file = File.join(root_dir, namespace, "version.rb")
                +      @version_file = File.join(root_dir, namespace, 'version.rb')
                @@ -14 +14 @@
                -      abspath == @version_file ? "VERSION" : super
                +      abspath == @version_file ? 'VERSION' : super
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:18.815903159 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:18.818903140 +0000
                @@ -22,2 +22,2 @@
                -      @tag = File.basename(root_file, ".rb")
                -      @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
                +      @tag = File.basename(root_file, '.rb')
                +      @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
                @@ -43 +43 @@
                -      expected_namespace_dir = @root_file.delete_suffix(".rb")
                +      expected_namespace_dir = @root_file.delete_suffix('.rb')
                @@ -49 +49 @@
                -        basename_without_ext = basename.delete_suffix(".rb")
                +        basename_without_ext = basename.delete_suffix('.rb')
        lib/zeitwerk/inflector.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb	2026-05-20 07:03:18.815903159 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb	2026-05-20 07:03:18.818903140 +0000
                @@ -8,3 +8,3 @@
                -    #   inflector.camelize("post", ...)             # => "Post"
                -    #   inflector.camelize("users_controller", ...) # => "UsersController"
                -    #   inflector.camelize("api", ...)              # => "Api"
                +    #   inflector.camelize('post', ...)             # => 'Post'
                +    #   inflector.camelize('users_controller', ...) # => 'UsersController'
                +    #   inflector.camelize('api', ...)              # => 'Api'
                @@ -23,2 +23,2 @@
                -    #     "html_parser"   => "HTMLParser",
                -    #     "mysql_adapter" => "MySQLAdapter"
                +    #     'html_parser'   => 'HTMLParser',
                +    #     'mysql_adapter' => 'MySQLAdapter'
                @@ -27,3 +27,3 @@
                -    #   inflector.camelize("html_parser", abspath)      # => "HTMLParser"
                -    #   inflector.camelize("mysql_adapter", abspath)    # => "MySQLAdapter"
                -    #   inflector.camelize("users_controller", abspath) # => "UsersController"
                +    #   inflector.camelize('html_parser', abspath)      # => 'HTMLParser'
                +    #   inflector.camelize('mysql_adapter', abspath)    # => 'MySQLAdapter'
                +    #   inflector.camelize('users_controller', abspath) # => 'UsersController'
        lib/zeitwerk/loader.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-05-20 07:03:18.815903159 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader.rb	2026-05-20 07:03:18.819903133 +0000
                @@ -3,2 +3,2 @@
                -require "monitor"
                -require "set"
                +require 'monitor'
                +require 'set'
                @@ -8,5 +8,5 @@
                -    require_relative "loader/helpers"
                -    require_relative "loader/callbacks"
                -    require_relative "loader/config"
                -    require_relative "loader/eager_load"
                -    require_relative "loader/file_system"
                +    require_relative 'loader/helpers'
                +    require_relative 'loader/callbacks'
                +    require_relative 'loader/config'
                +    require_relative 'loader/eager_load'
                +    require_relative 'loader/file_system'
                @@ -25,2 +25,2 @@
                -    #   "/Users/fxn/blog/app/models/user.rb"          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                -    #   "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                +    #   '/Users/fxn/blog/app/models/user.rb'          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                +    #   '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                @@ -104,0 +105 @@
                +    #: () -> void
                @@ -132 +133 @@
                -          define_autoloads_for_dir(root_dir, root_namespace)
                +          define_autoloads_for_dir(root_dir, root_namespace, external: true)
                @@ -155,0 +157,3 @@
                +        __unload
                +      end
                +    end
                @@ -157,33 +161,19 @@
                -        # We are going to keep track of the files that were required by our
                -        # autoloads to later remove them from $LOADED_FEATURES, thus making them
                -        # loadable by Kernel#require again.
                -        #
                -        # Directories are not stored in $LOADED_FEATURES, keeping track of files
                -        # is enough.
                -        unloaded_files = Set.new
                -
                -        autoloads.each do |abspath, cref|
                -          if cref.autoload?
                -            unload_autoload(cref)
                -          else
                -            # Could happen if loaded with require_relative. That is unsupported,
                -            # and the constant path would escape unloadable_cpath? This is just
                -            # defensive code to clean things up as much as we are able to.
                -            unload_cref(cref)
                -            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                -          end
                -        end
                -
                -        to_unload.each do |abspath, cref|
                -          unless on_unload_callbacks.empty?
                -            begin
                -              value = cref.get
                -            rescue ::NameError
                -              # Perhaps the user deleted the constant by hand, or perhaps an
                -              # autoload failed to define the expected constant but the user
                -              # rescued the exception.
                -            else
                -              run_on_unload_callbacks(cref, value, abspath)
                -            end
                -          end
                -
                +    # This is an internal method.
                +    #
                +    #: () -> void
                +    def __unload
                +      # We are going to keep track of the files that were required by our
                +      # autoloads to later remove them from $LOADED_FEATURES, thus making them
                +      # loadable by Kernel#require again.
                +      #
                +      # Directories are not stored in $LOADED_FEATURES, keeping track of files
                +      # is enough.
                +      unloaded_files = Set.new
                +
                +      autoloads.each do |abspath, cref|
                +        if cref.autoload?
                +          unload_autoload(cref)
                +        else
                +          # Could happen if loaded with require_relative. That is unsupported,
                +          # and the constant path would escape unloadable_cpath? This is just
                +          # defensive code to clean things up as much as we are able to.
                @@ -192,0 +183 @@
                +      end
                @@ -194,13 +185,11 @@
                -        unless unloaded_files.empty?
                -          # Bootsnap decorates Kernel#require to speed it up using a cache and
                -          # this optimization does not check if $LOADED_FEATURES has the file.
                -          #
                -          # To make it aware of changes, the gem defines singleton methods in
                -          # $LOADED_FEATURES:
                -          #
                -          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                -          #
                -          # Rails applications may depend on bootsnap, so for unloading to work
                -          # in that setting it is preferable that we restrict our API choice to
                -          # one of those methods.
                -          $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      to_unload.each do |abspath, cref|
                +        unless on_unload_callbacks.empty?
                +          begin
                +            value = cref.get
                +          rescue ::NameError
                +            # Perhaps the user deleted the constant by hand, or perhaps an
                +            # autoload failed to define the expected constant but the user
                +            # rescued the exception.
                +          else
                +            run_on_unload_callbacks(cref, value, abspath)
                +          end
                @@ -209,5 +198,3 @@
                -        autoloads.clear
                -        autoloaded_dirs.clear
                -        to_unload.clear
                -        namespace_dirs.clear
                -        shadowed_files.clear
                +        unload_cref(cref)
                +        unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                +      end
                @@ -215,2 +202,14 @@
                -        unregister_inceptions
                -        unregister_explicit_namespaces
                +      unless unloaded_files.empty?
                +        # Bootsnap decorates Kernel#require to speed it up using a cache and
                +        # this optimization does not check if $LOADED_FEATURES has the file.
                +        #
                +        # To make it aware of changes, the gem defines singleton methods in
                +        # $LOADED_FEATURES:
                +        #
                +        #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +        #
                +        # Rails applications may depend on bootsnap, so for unloading to work
                +        # in that setting it is preferable that we restrict our API choice to
                +        # one of those methods.
                +        $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      end
                @@ -218 +217,5 @@
                -        Registry.autoloads.unregister_loader(self)
                +      autoloads.clear
                +      autoloaded_dirs.clear
                +      to_unload.clear
                +      namespace_dirs.clear
                +      shadowed_files.clear
                @@ -220,3 +223,7 @@
                -        @setup        = false
                -        @eager_loaded = false
                -      end
                +      unregister_inceptions
                +      unregister_explicit_namespaces
                +
                +      Registry.autoloads.unregister_loader(self)
                +
                +      @setup        = false
                +      @eager_loaded = false
                @@ -236,0 +244 @@
                +
                @@ -238,0 +247,2 @@
                +      recompute_collapse_parents
                +
                @@ -255 +265 @@
                -          prefix = cpath == "Object" ? "" : cpath + "::"
                +          prefix = cpath == 'Object' ? '' : cpath + '::'
                @@ -257 +267 @@
                -          @fs.ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
                @@ -259,5 +269,2 @@
                -              basename.delete_suffix!(".rb")
                -              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                -            else
                -              if collapse?(abspath)
                -                queue << [abspath, cpath]
                +              if basename == @nsfile
                +                result[abspath] = cpath
                @@ -265 +272,2 @@
                -                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                +                basename.delete_suffix!('.rb')
                +                result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266,0 +275,4 @@
                +            elsif collapse?(abspath)
                +              queue.unshift([abspath, cpath])
                +            else
                +              queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
                @@ -288,2 +300,2 @@
                -      if :file == ftype
                -        basename = File.basename(abspath, ".rb")
                +      if ftype == :file
                +        basename = File.basename(abspath)
                @@ -292 +304 @@
                -        paths << [basename, abspath]
                +        paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
                @@ -318 +330 @@
                -          cnames.join("::")
                +          cnames.join('::')
                @@ -320 +332 @@
                -          "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
                +          "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
                @@ -387 +399 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -390 +402 @@
                -      #   loader.tag = File.basename(__FILE__, ".rb")
                +      #   loader.tag = File.basename(__FILE__, '.rb')
                @@ -408 +420 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -411 +423 @@
                -      #   loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
                +      #   loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
                @@ -428 +440 @@
                -          raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
                +          raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
                @@ -476,2 +488,8 @@
                -    #: (String, Module) -> void
                -    private def define_autoloads_for_dir(dir, parent)
                +    # Scans `dir` and sets autoloads in `mod` for the constants its contents are
                +    # expected to define.
                +    #
                +    # The `external` flag indicates whether `mod` has been externally defined,
                +    # as is the case with root namespaces or reopened third-party namespaces.
                +    #
                +    #: (String, Module, external: boolish) -> void
                +    private def define_autoloads_for_dir(dir, mod, external:)
                @@ -480,9 +498,8 @@
                -          basename.delete_suffix!(".rb")
                -          cref = Cref.new(parent, cname_for(basename, abspath))
                -          autoload_file(cref, abspath)
                -        else
                -          if collapse?(abspath)
                -            define_autoloads_for_dir(abspath, parent)
                -          else
                -            cref = Cref.new(parent, cname_for(basename, abspath))
                -            autoload_subdir(cref, abspath)
                +          if basename == @nsfile
                +            if external
                +              cpath = real_mod_name(mod)
                +              location = Object.const_source_location(cpath)&.join(':')
                +              location = nil if location&.empty?
                +              raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
                +            end
                +            next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
                @@ -489,0 +507,7 @@
                +
                +          basename.delete_suffix!('.rb')
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_file(cref, abspath)
                +        else
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_subdir(cref, abspath, external:)
                @@ -495 +519,25 @@
                -    private def autoload_subdir(cref, subdir)
                +    private def visit_file(cref, file)
                +      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                +        if @fs.rb_extension?(autoload_path)
                +          if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
                +          end
                +          shadowed_files << file
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                +        else
                +          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                +        end
                +      elsif cref.defined?
                +        shadowed_files << file
                +        if location = cref.location
                +          log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
                +        else
                +          log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
                +        end
                +      else
                +        define_autoload(cref, file)
                +      end
                +    end
                +
                +    #: (Zeitwerk::Cref, String, external: boolish) -> void
                +    private def visit_subdir(cref, subdir, external:)
                @@ -497,0 +546,6 @@
                +          # The namespace that corresponds to this subdirectory is defined in a
                +          # file, either regular or nsfile. Therefore, a nsfile would be a
                +          # duplication.
                +          if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
                +          end
                @@ -499,2 +553 @@
                -          # constant has been found. This means we are dealing with an explicit
                -          # namespace whose definition was seen first.
                +          # constant has been found. This is an explicit namespace.
                @@ -502,3 +555,2 @@
                -          # Registering is idempotent, and we have to keep the autoload pointing
                -          # to the file. This may run again if more directories are found later
                -          # on, no big deal.
                +          # The namespace may be spread over multiple directories and perhaps it
                +          # was already registered, but registering is idempotent, just do it.
                @@ -505,0 +558,3 @@
                +        elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          # Scanning found a matching directory first, and now we saw a nsfile.
                +          promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
                @@ -507,3 +561,0 @@
                -        # If the existing autoload points to a file, it has to be preserved, if
                -        # not, it is fine as it is. In either case, we do not need to override.
                -        # Just remember the subdirectory conforms this namespace.
                @@ -512 +564,6 @@
                -        # First time we find this namespace, set an autoload for it.
                +        if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          define_autoload(cref, nsfile_abspath)
                +          register_explicit_namespace(cref)
                +        else
                +          define_autoload(cref, subdir)
                +        end
                @@ -514 +570,0 @@
                -        define_autoload(cref, subdir)
                @@ -519,19 +575 @@
                -        define_autoloads_for_dir(subdir, cref.get)
                -      end
                -    end
                -
                -    #: (Zeitwerk::Cref, String) -> void
                -    private def autoload_file(cref, file)
                -      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                -        # First autoload for a Ruby file wins, just ignore subsequent ones.
                -        if @fs.rb_extension?(autoload_path)
                -          shadowed_files << file
                -          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                -        else
                -          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                -        end
                -      elsif cref.defined?
                -        shadowed_files << file
                -        log { "file #{file} is ignored because #{cref} is already defined" }
                -      else
                -        define_autoload(cref, file)
                +        define_autoloads_for_dir(subdir, cref.get, external:)
                @@ -611 +649 @@
                -        require "pp" # Needed to have pretty_inspect available.
                +        require 'pp' # Needed to have pretty_inspect available.
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:18.815903159 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:18.819903133 +0000
                @@ -80 +80 @@
                -        define_autoloads_for_dir(dir, namespace)
                +        define_autoloads_for_dir(dir, namespace, external: false)
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:18.815903159 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:18.819903133 +0000
                @@ -3,2 +3,2 @@
                -require "set"
                -require "securerandom"
                +require 'set'
                +require 'securerandom'
                @@ -18,2 +18,2 @@
                -  #   "/Users/fxn/blog/app/channels" => Object,
                -  #   "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
                +  #   '/Users/fxn/blog/app/channels' => Object,
                +  #   '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
                @@ -31,0 +32,6 @@
                +  # Basename of files that define namespaces. For example, if `nsfile` is
                +  # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
                +  #
                +  #: String?
                +  attr_reader :nsfile
                +
                @@ -53 +59 @@
                -  # glob patterns were expanded. Computed on setup, and recomputed on reload.
                +  # glob patterns were expanded. Computed on setup and recomputed on reload.
                @@ -58,0 +65,8 @@
                +  # Absolute paths of directories that are parents of collapsed directories.
                +  # This is a cache to optimize some tree walks. Computed on setup and
                +  # recomputed on reload.
                +  #
                +  #: Set[String]
                +  attr_reader :collapse_parents
                +  private :collapse_parents
                +
                @@ -84,0 +99 @@
                +  #: () -> void
                @@ -90,0 +106 @@
                +    @nsfile                 = nil
                @@ -94,0 +111 @@
                +    @collapse_parents       = Set.new
                @@ -115 +132 @@
                -      raise Zeitwerk::Error, "root namespaces cannot be anonymous"
                +      raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
                @@ -143,0 +161,12 @@
                +  #: (String?) -> void ! TypeError, ArgumentError
                +  def nsfile=(nsfile)
                +    unless nsfile.nil?
                +      raise TypeError,     'nsfiles must be strings'              unless nsfile.is_a?(String)
                +      raise ArgumentError, 'nsfiles must have .rb extension'      unless @fs.rb_extension?(nsfile)
                +      raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
                +      raise ArgumentError, 'nsfiles cannot be hidden'             if @fs.hidden?(nsfile)
                +    end
                +
                +    @nsfile = nsfile
                +  end
                +
                @@ -179 +208 @@
                -        raise Zeitwerk::Error, "cannot enable reloading after setup"
                +        raise Zeitwerk::Error, 'cannot enable reloading after setup'
                @@ -217 +246,5 @@
                -      collapse_dirs.merge(expand_glob_patterns(glob_patterns))
                +      new_collapse_dirs = expand_glob_patterns(glob_patterns)
                +      collapse_dirs.merge(new_collapse_dirs)
                +      new_collapse_dirs.each do |dir|
                +        collapse_parents << File.dirname(dir)
                +      end
                @@ -236,2 +269,2 @@
                -  #   loader.on_load("SomeApiClient") do |klass, _abspath|
                -  #     klass.endpoint = "https://api.dev"
                +  #   loader.on_load('SomeApiClient') do |klass, _abspath|
                +  #     klass.endpoint = 'https://api.dev'
                @@ -248 +281 @@
                -    raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -259 +292 @@
                -  #   loader.on_unload("Country") do |klass, _abspath|
                +  #   loader.on_unload('Country') do |klass, _abspath|
                @@ -271 +304 @@
                -    raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -318,0 +352,10 @@
                +  internal def collapse?(dir)
                +    collapse_dirs.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                +  internal def collapse_parent?(dir)
                +    collapse_parents.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                @@ -331,5 +373,0 @@
                -  #: (String) -> bool
                -  private def collapse?(dir)
                -    collapse_dirs.member?(dir)
                -  end
                -
                @@ -355,0 +394,8 @@
                +  end
                +
                +  #: () -> void
                +  private def recompute_collapse_parents
                +    collapse_parents.clear
                +    collapse_dirs.each do |dir|
                +      collapse_parents << File.dirname(dir)
                +    end
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:18.815903159 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:18.819903133 +0000
                @@ -3,4 +3,5 @@
                -  # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
                -  # shadowed files are not eager loaded. You can opt-out specifically in
                -  # specific files and directories with `do_not_eager_load`, and that can be
                -  # overridden passing `force: true`.
                +  # need to be in `$LOAD_PATH`, absolute file names are used.
                +  #
                +  # Ignored files are not eager loaded. You can opt-out specifically in specific
                +  # files and directories with `do_not_eager_load`, and that can be overridden
                +  # passing `force: true`.
                @@ -14 +15 @@
                -      log { "eager load start" }
                +      log { 'eager load start' }
                @@ -27 +28 @@
                -      log { "eager load end" }
                +      log { 'eager load end' }
                @@ -94 +95 @@
                -        if root_namespace_name.start_with?(mod_name + "::")
                +        if root_namespace_name.start_with?(mod_name + '::')
                @@ -98 +99 @@
                -        elsif mod_name.start_with?(root_namespace_name + "::")
                +        elsif mod_name.start_with?(root_namespace_name + '::')
                @@ -122 +123 @@
                -    file_basename = File.basename(abspath, ".rb")
                +    file_basename = File.basename(abspath)
                @@ -141,2 +141,0 @@
                -    base_cname = cname_for(file_basename, abspath)
                -
                @@ -149,3 +148,8 @@
                -    raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
                -
                -    namespace.const_get(base_cname, false)
                +    if file_basename == @nsfile
                +      namespace
                +    elsif shadowed_file?(abspath)
                +      raise Zeitwerk::Error.new("#{abspath} is shadowed")
                +    else
                +      cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
                +      namespace.const_get(cname, false)
                +    end
                @@ -174,6 +178,2 @@
                -          if collapse?(abspath)
                -            queue << [abspath, namespace]
                -          else
                -            cname = cname_for(basename, abspath)
                -            queue << [abspath, namespace.const_get(cname, false)]
                -          end
                +          cname = cname_for(basename, abspath)
                +          queue << [abspath, namespace.const_get(cname, false)]
                @@ -194 +194 @@
                -      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
                +      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
                @@ -207 +207 @@
                -    suffix.split("::").each do |segment|
                +    suffix.split('::').each do |segment|
                @@ -210,5 +210 @@
                -          next unless ftype == :directory
                -
                -          if collapse?(abspath)
                -            dirs << abspath
                -          elsif segment == cname_for(basename, abspath).to_s
                +          if ftype == :directory && segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:18.815903159 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:18.819903133 +0000
                @@ -14,0 +15,14 @@
                +  # This method lists directories, filtering out the following:
                +  #
                +  # - Hidden entries.
                +  # - Ignored entries.
                +  # - Files whose extension is not `.rb`.
                +  # - Nested root directories, since they represent separate trees.
                +  # - Subdirectories that (recursively) contain no Ruby files.
                +  #
                +  # If `collapse` is true, collapsed directories are not yielded, instead, the
                +  # method recurses so that the caller gets a conceptually flat listing.
                +  #
                +  # For every entry that is not excluded, `ls` yields its basename, absolute
                +  # path, and file type, which can only be :file or :directory.
                +  #
                @@ -16 +30 @@
                -  def ls(dir)
                +  def ls(dir, collapse: true, &block)
                @@ -27,3 +41,8 @@
                -      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                -        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                -        next
                +      if ftype == :directory
                +        if !has_at_least_one_ruby_file?(abspath)
                +          @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +          next
                +        elsif collapse && @loader.__collapse?(abspath)
                +          ls(abspath, collapse: collapse, &block)
                +          next
                +        end
                @@ -41 +60,38 @@
                -      break if basename == "/"
                +      break if basename == '/'
                +    end
                +  end
                +
                +  # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
                +  # there is none, it returns `nil`.
                +  #
                +  # This method accounts for collapsed directories, which conceptually allow for
                +  # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
                +  #
                +  #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
                +  def has_exactly_one_nsfile?(cref, dir)
                +    return unless @loader.nsfile
                +
                +    # When `dir` does not have any collapsed directories a simple lookup
                +    # suffices. This is a common case worth optimizing.
                +    unless @loader.__collapse_parent?(dir)
                +      nsfile_abspath = File.join(dir, @loader.nsfile)
                +      if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
                +        return nsfile_abspath
                +      end
                +      return
                +    end
                +
                +    nsfile = nil
                +
                +    to_visit = [dir]
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |basename, abspath, ftype|
                +        if ftype == :file && basename == @loader.nsfile
                +          if nsfile
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
                +          end
                +          nsfile = abspath
                +        elsif ftype == :directory && @loader.__collapse?(abspath)
                +          to_visit << abspath
                +        end
                +      end
                @@ -42,0 +99,2 @@
                +
                +    nsfile
                @@ -58 +116 @@
                -    path.end_with?(".rb")
                +    path.end_with?('.rb')
                @@ -68 +126 @@
                -    basename.start_with?(".")
                +    basename.start_with?('.')
                @@ -83 +141 @@
                -        return true if :file == ftype
                +        return true if ftype == :file
                @@ -99,12 +157,3 @@
                -      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
                +      if ftype == :file
                +        yield basename, abspath, ftype
                +      else
                @@ -138 +187 @@
                -        elsif :directory == ftype
                +        elsif ftype == :directory
                @@ -141 +190 @@
                -        elsif :link == ftype
                +        elsif ftype == :link
                @@ -158,3 +207 @@
                -          if dir?(abspath)
                -            yield basename, abspath, :directory
                -          end
                +          yield basename, abspath, :directory if dir?(abspath)
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:18.816903152 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:18.819903133 +0000
                @@ -15 +15 @@
                -    if cname.include?("::")
                +    if cname.include?('::')
                @@ -28 +28 @@
                -      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
        lib/zeitwerk/registry.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-05-20 07:03:18.816903152 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/registry.rb	2026-05-20 07:03:18.819903133 +0000
                @@ -5,4 +5,4 @@
                -    require_relative "registry/autoloads"
                -    require_relative "registry/explicit_namespaces"
                -    require_relative "registry/inceptions"
                -    require_relative "registry/loaders"
                +    require_relative 'registry/autoloads'
                +    require_relative 'registry/explicit_namespaces'
                +    require_relative 'registry/inceptions'
                +    require_relative 'registry/loaders'
                @@ -57,2 +57,2 @@
                -              new_root_dir_slash = new_root_dir + "/"
                -              existing_root_dir_slash = existing_root_dir + "/"
                +              new_root_dir_slash = new_root_dir + '/'
                +              existing_root_dir_slash = existing_root_dir + '/'
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:18.816903152 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:18.820903127 +0000
                @@ -9,2 +9,2 @@
                -    def each(&)
                -      @loaders.each(&)
                +    def each(&block)
                +      @loaders.each(&block)
        lib/zeitwerk/version.rb
                --- /tmp/d20260520-397-2qbsyh/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-05-20 07:03:18.816903152 +0000
                +++ /tmp/d20260520-397-2qbsyh/zeitwerk-2.8.1/lib/zeitwerk/version.rb	2026-05-20 07:03:18.820903127 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.5"
                +  VERSION = '2.8.1'

@github-actions

Copy link
Copy Markdown
Contributor

gem compare --diff zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
        README.md
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/README.md	2026-05-20 07:03:27.387262953 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/README.md	2026-05-20 07:03:27.395262873 +0000
                @@ -21,0 +22,2 @@
                +    - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
                +    - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
                @@ -101 +103 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -184 +186 @@
                -loader.inflector.inflect("max_retries" => "MAX_RETRIES")
                +loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
                @@ -223,2 +225,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -267 +269,6 @@
                -Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
                +Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
                +
                +<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
                +#### Explicit namespaces defined in ordinary files
                +
                +Let's consider:
                @@ -274 +281 @@
                -There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
                +Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
                @@ -276 +283,3 @@
                -The classes and modules from the namespace are already available in the body of the class or module defining it:
                +In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
                +
                +The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
                @@ -287 +296,42 @@
                -An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
                +<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
                +#### Explicit namespaces defined in nsfiles
                +
                +If the loader has an nsfile configured (defaults to `nil`):
                +
                +```ruby
                +loader.nsfile = 'ns.rb' # must be set before setup
                +```
                +
                +you can alternatively define the explicit namespace inside its directory:
                +
                +```
                +my_component/ns.rb     -> MyComponent
                +my_component/widget.rb -> MyComponent::Widget
                +```
                +
                +This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
                +
                +A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
                +
                +Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
                +
                +```
                +my_component/src/ns.rb            -> MyComponent
                +my_component/src/widget.rb        -> MyComponent::Widget
                +my_component/assets/widget.js
                +my_component/tests/test_widget.rb
                +```
                +
                +Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
                +
                +However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
                +
                +Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
                +
                +Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
                +
                +```ruby
                +loader.nsfile = 'index.rb'
                +```
                +
                +there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
                @@ -375 +425 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -377 +427 @@
                -loader.tag = File.basename(__FILE__, ".rb")
                +loader.tag = File.basename(__FILE__, '.rb')
                @@ -387 +437 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -433 +483 @@
                -gem "net-http-niche_feature"
                +gem 'net-http-niche_feature'
                @@ -446 +496 @@
                -require "net/http/niche_feature"
                +require 'net/http/niche_feature'
                @@ -451,2 +501,2 @@
                -require "net/http"
                -require "zeitwerk"
                +require 'net/http'
                +require 'zeitwerk'
                @@ -467 +517 @@
                -  VERSION = "1.0.0"
                +  VERSION = '1.0.0'
                @@ -489 +539 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -498 +548 @@
                -That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                +That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                @@ -685 +735 @@
                -require "concurrent/atomic/read_write_lock"
                +require 'concurrent/atomic/read_write_lock'
                @@ -730,2 +780,2 @@
                -  "html_parser"   => "HTMLParser",
                -  "mysql_adapter" => "MySQLAdapter"
                +  'html_parser'   => 'HTMLParser',
                +  'mysql_adapter' => 'MySQLAdapter'
                @@ -738,2 +788,2 @@
                -loader.inflector.inflect "html_parser" => "HTMLParser"
                -loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
                +loader.inflector.inflect 'html_parser' => 'HTMLParser'
                +loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
                @@ -745 +795 @@
                -loader.inflector.inflect("xml" => "XML")
                +loader.inflector.inflect('xml' => 'XML')
                @@ -760,2 +810,2 @@
                -  "xml"        => "XML",
                -  "xml_parser" => "XMLParser"
                +  'xml'        => 'XML',
                +  'xml_parser' => 'XMLParser'
                @@ -816 +866 @@
                -      "HTML" + super($1, abspath)
                +      'HTML' + super($1, abspath)
                @@ -847,2 +897,2 @@
                -require "zeitwerk"
                -require_relative "my_gem/inflector"
                +require 'zeitwerk'
                +require_relative 'my_gem/inflector'
                @@ -916,2 +966,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.dev"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.dev'
                @@ -921,2 +971,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.prod"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.prod'
                @@ -966 +1016 @@
                -loader.on_unload("Country") do |klass, _abspath|
                +loader.on_unload('Country') do |klass, _abspath|
                @@ -1051 +1101 @@
                -loader.tag = "grep_me"
                +loader.tag = 'grep_me'
                @@ -1107 +1157 @@
                -require_relative "my_gem/core_ext/kernel"
                +require_relative 'my_gem/core_ext/kernel'
                @@ -1121 +1171 @@
                -require "pg"
                +require 'pg'
                @@ -1159 +1209 @@
                -require "foo"
                +require 'foo'
                @@ -1228,2 +1278,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -1231 +1281 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -1250,2 +1300,2 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.dirs # => ["/foo"]
                +loader.push_dir(Pathname.new('/foo'))
                +loader.dirs # => ['/foo']
                @@ -1258,3 +1308,3 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.push_dir(Pathname.new("/bar"), namespace: Bar)
                -loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
                +loader.push_dir(Pathname.new('/foo'))
                +loader.push_dir(Pathname.new('/bar'), namespace: Bar)
                +loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
                @@ -1287,4 +1337,4 @@
                -loader.cpath_expected_at("app/models")                  # => "Object"
                -loader.cpath_expected_at("app/models/user.rb")          # => "User"
                -loader.cpath_expected_at("app/models/hotel")            # => "Hotel"
                -loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
                +loader.cpath_expected_at('app/models')                  # => 'Object'
                +loader.cpath_expected_at('app/models/user.rb')          # => 'User'
                +loader.cpath_expected_at('app/models/hotel')            # => 'Hotel'
                +loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
                @@ -1296,3 +1346,3 @@
                -loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
                -loader.cpath_expected_at("a/b/collapsed")   # => "A::B", edge case
                -loader.cpath_expected_at("a/b")             # => "A::B"
                +loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
                +loader.cpath_expected_at('a/b/collapsed')   # => 'A::B', edge case
                +loader.cpath_expected_at('a/b')             # => 'A::B'
                @@ -1306 +1356 @@
                -loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
                +loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
                @@ -1312 +1362 @@
                -loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
                +loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
                @@ -1315 +1365,3 @@
                -This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1332 +1383,0 @@
                -lib/my_gem/ignored.rb
                @@ -1335,0 +1387 @@
                +lib/my_gem/ignored.rb
                @@ -1343,9 +1395,9 @@
                -  "/.../lib"                           => "Object",
                -  "/.../lib/my_gem.rb"                 => "MyGem",
                -  "/.../lib/my_gem"                    => "MyGem",
                -  "/.../lib/my_gem/version.rb"         => "MyGem::VERSION",
                -  "/.../lib/my_gem/drivers"            => "MyGem::Drivers",
                -  "/.../lib/my_gem/drivers/unix.rb"    => "MyGem::Drivers::Unix",
                -  "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
                -  "/.../lib/my_gem/collapsed"          => "MyGem",
                -  "/.../lib/my_gem/collapsed/foo.rb"   => "MyGem::Foo"
                +  '/.../lib'                           => 'Object',
                +  '/.../lib/my_gem.rb'                 => 'MyGem',
                +  '/.../lib/my_gem'                    => 'MyGem',
                +  '/.../lib/my_gem/version.rb'         => 'MyGem::VERSION',
                +  '/.../lib/my_gem/drivers'            => 'MyGem::Drivers',
                +  '/.../lib/my_gem/drivers/unix.rb'    => 'MyGem::Drivers::Unix',
                +  '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
                +  '/.../lib/my_gem/collapsed'          => 'MyGem',
                +  '/.../lib/my_gem/collapsed/foo.rb'   => 'MyGem::Foo'
                @@ -1357 +1409 @@
                -The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
                +The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
                @@ -1363 +1415,3 @@
                -This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1434,2 +1488,2 @@
                -test "capitalizes the first letter" do
                -  assert_equal "User", camelize("user")
                +test 'capitalizes the first letter' do
                +  assert_equal 'User', camelize('user')
                @@ -1449 +1503 @@
                -Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
                +Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
        lib/zeitwerk.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk.rb	2026-05-20 07:03:27.387262953 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk.rb	2026-05-20 07:03:27.395262873 +0000
                @@ -4,11 +4,11 @@
                -  require_relative "zeitwerk/real_mod_name"
                -  require_relative "zeitwerk/internal"
                -  require_relative "zeitwerk/cref"
                -  require_relative "zeitwerk/loader"
                -  require_relative "zeitwerk/gem_loader"
                -  require_relative "zeitwerk/registry"
                -  require_relative "zeitwerk/inflector"
                -  require_relative "zeitwerk/gem_inflector"
                -  require_relative "zeitwerk/null_inflector"
                -  require_relative "zeitwerk/error"
                -  require_relative "zeitwerk/version"
                +  require_relative 'zeitwerk/real_mod_name'
                +  require_relative 'zeitwerk/internal'
                +  require_relative 'zeitwerk/cref'
                +  require_relative 'zeitwerk/loader'
                +  require_relative 'zeitwerk/gem_loader'
                +  require_relative 'zeitwerk/registry'
                +  require_relative 'zeitwerk/inflector'
                +  require_relative 'zeitwerk/gem_inflector'
                +  require_relative 'zeitwerk/null_inflector'
                +  require_relative 'zeitwerk/error'
                +  require_relative 'zeitwerk/version'
                @@ -16,2 +16,2 @@
                -  require_relative "zeitwerk/core_ext/kernel"
                -  require_relative "zeitwerk/core_ext/module"
                +  require_relative 'zeitwerk/core_ext/kernel'
                +  require_relative 'zeitwerk/core_ext/module'
        lib/zeitwerk/core_ext/kernel.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:27.387262953 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:27.396262863 +0000
                @@ -25 +25 @@
                -      if path.end_with?(".rb")
                +      if path.end_with?('.rb')
        lib/zeitwerk/cref.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/cref.rb	2026-05-20 07:03:27.388262943 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/cref.rb	2026-05-20 07:03:27.396262863 +0000
                @@ -14 +14 @@
                -  require_relative "cref/map"
                +  require_relative 'cref/map'
                @@ -67,0 +68,7 @@
                +  end
                +
                +  #: () -> String?
                +  def location
                +    if (location = @mod.const_source_location(@cname)) && !location.empty?
                +      location.join(':')
                +    end
        lib/zeitwerk/cref/map.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:27.388262943 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:27.397262853 +0000
                @@ -30 +30 @@
                -#      { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
                +#      { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
        lib/zeitwerk/error.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/error.rb	2026-05-20 07:03:27.388262943 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/error.rb	2026-05-20 07:03:27.397262853 +0000
                @@ -20 +20,12 @@
                -      super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
                +      super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
                +    end
                +  end
                +
                +  class ConflictingNamespaceDefinitionError < Error
                +    #: (String, location: String?, conflicting_file: String) -> void
                +    def initialize(cpath, location:, conflicting_file:)
                +      if location
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
                +      else
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
                +      end
        lib/zeitwerk/gem_inflector.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:27.388262943 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:27.397262853 +0000
                @@ -7 +7 @@
                -      namespace     = File.basename(root_file, ".rb")
                +      namespace     = File.basename(root_file, '.rb')
                @@ -9 +9 @@
                -      @version_file = File.join(root_dir, namespace, "version.rb")
                +      @version_file = File.join(root_dir, namespace, 'version.rb')
                @@ -14 +14 @@
                -      abspath == @version_file ? "VERSION" : super
                +      abspath == @version_file ? 'VERSION' : super
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:27.388262943 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:27.397262853 +0000
                @@ -22,2 +22,2 @@
                -      @tag = File.basename(root_file, ".rb")
                -      @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
                +      @tag = File.basename(root_file, '.rb')
                +      @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
                @@ -43 +43 @@
                -      expected_namespace_dir = @root_file.delete_suffix(".rb")
                +      expected_namespace_dir = @root_file.delete_suffix('.rb')
                @@ -49 +49 @@
                -        basename_without_ext = basename.delete_suffix(".rb")
                +        basename_without_ext = basename.delete_suffix('.rb')
        lib/zeitwerk/inflector.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb	2026-05-20 07:03:27.388262943 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb	2026-05-20 07:03:27.397262853 +0000
                @@ -8,3 +8,3 @@
                -    #   inflector.camelize("post", ...)             # => "Post"
                -    #   inflector.camelize("users_controller", ...) # => "UsersController"
                -    #   inflector.camelize("api", ...)              # => "Api"
                +    #   inflector.camelize('post', ...)             # => 'Post'
                +    #   inflector.camelize('users_controller', ...) # => 'UsersController'
                +    #   inflector.camelize('api', ...)              # => 'Api'
                @@ -23,2 +23,2 @@
                -    #     "html_parser"   => "HTMLParser",
                -    #     "mysql_adapter" => "MySQLAdapter"
                +    #     'html_parser'   => 'HTMLParser',
                +    #     'mysql_adapter' => 'MySQLAdapter'
                @@ -27,3 +27,3 @@
                -    #   inflector.camelize("html_parser", abspath)      # => "HTMLParser"
                -    #   inflector.camelize("mysql_adapter", abspath)    # => "MySQLAdapter"
                -    #   inflector.camelize("users_controller", abspath) # => "UsersController"
                +    #   inflector.camelize('html_parser', abspath)      # => 'HTMLParser'
                +    #   inflector.camelize('mysql_adapter', abspath)    # => 'MySQLAdapter'
                +    #   inflector.camelize('users_controller', abspath) # => 'UsersController'
        lib/zeitwerk/loader.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-05-20 07:03:27.389262933 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader.rb	2026-05-20 07:03:27.398262843 +0000
                @@ -3,2 +3,2 @@
                -require "monitor"
                -require "set"
                +require 'monitor'
                +require 'set'
                @@ -8,5 +8,5 @@
                -    require_relative "loader/helpers"
                -    require_relative "loader/callbacks"
                -    require_relative "loader/config"
                -    require_relative "loader/eager_load"
                -    require_relative "loader/file_system"
                +    require_relative 'loader/helpers'
                +    require_relative 'loader/callbacks'
                +    require_relative 'loader/config'
                +    require_relative 'loader/eager_load'
                +    require_relative 'loader/file_system'
                @@ -25,2 +25,2 @@
                -    #   "/Users/fxn/blog/app/models/user.rb"          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                -    #   "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                +    #   '/Users/fxn/blog/app/models/user.rb'          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                +    #   '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                @@ -104,0 +105 @@
                +    #: () -> void
                @@ -132 +133 @@
                -          define_autoloads_for_dir(root_dir, root_namespace)
                +          define_autoloads_for_dir(root_dir, root_namespace, external: true)
                @@ -155,0 +157,3 @@
                +        __unload
                +      end
                +    end
                @@ -157,33 +161,19 @@
                -        # We are going to keep track of the files that were required by our
                -        # autoloads to later remove them from $LOADED_FEATURES, thus making them
                -        # loadable by Kernel#require again.
                -        #
                -        # Directories are not stored in $LOADED_FEATURES, keeping track of files
                -        # is enough.
                -        unloaded_files = Set.new
                -
                -        autoloads.each do |abspath, cref|
                -          if cref.autoload?
                -            unload_autoload(cref)
                -          else
                -            # Could happen if loaded with require_relative. That is unsupported,
                -            # and the constant path would escape unloadable_cpath? This is just
                -            # defensive code to clean things up as much as we are able to.
                -            unload_cref(cref)
                -            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                -          end
                -        end
                -
                -        to_unload.each do |abspath, cref|
                -          unless on_unload_callbacks.empty?
                -            begin
                -              value = cref.get
                -            rescue ::NameError
                -              # Perhaps the user deleted the constant by hand, or perhaps an
                -              # autoload failed to define the expected constant but the user
                -              # rescued the exception.
                -            else
                -              run_on_unload_callbacks(cref, value, abspath)
                -            end
                -          end
                -
                +    # This is an internal method.
                +    #
                +    #: () -> void
                +    def __unload
                +      # We are going to keep track of the files that were required by our
                +      # autoloads to later remove them from $LOADED_FEATURES, thus making them
                +      # loadable by Kernel#require again.
                +      #
                +      # Directories are not stored in $LOADED_FEATURES, keeping track of files
                +      # is enough.
                +      unloaded_files = Set.new
                +
                +      autoloads.each do |abspath, cref|
                +        if cref.autoload?
                +          unload_autoload(cref)
                +        else
                +          # Could happen if loaded with require_relative. That is unsupported,
                +          # and the constant path would escape unloadable_cpath? This is just
                +          # defensive code to clean things up as much as we are able to.
                @@ -192,0 +183 @@
                +      end
                @@ -194,13 +185,11 @@
                -        unless unloaded_files.empty?
                -          # Bootsnap decorates Kernel#require to speed it up using a cache and
                -          # this optimization does not check if $LOADED_FEATURES has the file.
                -          #
                -          # To make it aware of changes, the gem defines singleton methods in
                -          # $LOADED_FEATURES:
                -          #
                -          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                -          #
                -          # Rails applications may depend on bootsnap, so for unloading to work
                -          # in that setting it is preferable that we restrict our API choice to
                -          # one of those methods.
                -          $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      to_unload.each do |abspath, cref|
                +        unless on_unload_callbacks.empty?
                +          begin
                +            value = cref.get
                +          rescue ::NameError
                +            # Perhaps the user deleted the constant by hand, or perhaps an
                +            # autoload failed to define the expected constant but the user
                +            # rescued the exception.
                +          else
                +            run_on_unload_callbacks(cref, value, abspath)
                +          end
                @@ -209,5 +198,3 @@
                -        autoloads.clear
                -        autoloaded_dirs.clear
                -        to_unload.clear
                -        namespace_dirs.clear
                -        shadowed_files.clear
                +        unload_cref(cref)
                +        unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                +      end
                @@ -215,2 +202,14 @@
                -        unregister_inceptions
                -        unregister_explicit_namespaces
                +      unless unloaded_files.empty?
                +        # Bootsnap decorates Kernel#require to speed it up using a cache and
                +        # this optimization does not check if $LOADED_FEATURES has the file.
                +        #
                +        # To make it aware of changes, the gem defines singleton methods in
                +        # $LOADED_FEATURES:
                +        #
                +        #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +        #
                +        # Rails applications may depend on bootsnap, so for unloading to work
                +        # in that setting it is preferable that we restrict our API choice to
                +        # one of those methods.
                +        $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      end
                @@ -218 +217,5 @@
                -        Registry.autoloads.unregister_loader(self)
                +      autoloads.clear
                +      autoloaded_dirs.clear
                +      to_unload.clear
                +      namespace_dirs.clear
                +      shadowed_files.clear
                @@ -220,3 +223,7 @@
                -        @setup        = false
                -        @eager_loaded = false
                -      end
                +      unregister_inceptions
                +      unregister_explicit_namespaces
                +
                +      Registry.autoloads.unregister_loader(self)
                +
                +      @setup        = false
                +      @eager_loaded = false
                @@ -236,0 +244 @@
                +
                @@ -238,0 +247,2 @@
                +      recompute_collapse_parents
                +
                @@ -255 +265 @@
                -          prefix = cpath == "Object" ? "" : cpath + "::"
                +          prefix = cpath == 'Object' ? '' : cpath + '::'
                @@ -257 +267 @@
                -          @fs.ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
                @@ -259,5 +269,2 @@
                -              basename.delete_suffix!(".rb")
                -              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                -            else
                -              if collapse?(abspath)
                -                queue << [abspath, cpath]
                +              if basename == @nsfile
                +                result[abspath] = cpath
                @@ -265 +272,2 @@
                -                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                +                basename.delete_suffix!('.rb')
                +                result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266,0 +275,4 @@
                +            elsif collapse?(abspath)
                +              queue.unshift([abspath, cpath])
                +            else
                +              queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
                @@ -288,2 +300,2 @@
                -      if :file == ftype
                -        basename = File.basename(abspath, ".rb")
                +      if ftype == :file
                +        basename = File.basename(abspath)
                @@ -292 +304 @@
                -        paths << [basename, abspath]
                +        paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
                @@ -318 +330 @@
                -          cnames.join("::")
                +          cnames.join('::')
                @@ -320 +332 @@
                -          "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
                +          "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
                @@ -387 +399 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -390 +402 @@
                -      #   loader.tag = File.basename(__FILE__, ".rb")
                +      #   loader.tag = File.basename(__FILE__, '.rb')
                @@ -408 +420 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -411 +423 @@
                -      #   loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
                +      #   loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
                @@ -428 +440 @@
                -          raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
                +          raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
                @@ -476,2 +488,8 @@
                -    #: (String, Module) -> void
                -    private def define_autoloads_for_dir(dir, parent)
                +    # Scans `dir` and sets autoloads in `mod` for the constants its contents are
                +    # expected to define.
                +    #
                +    # The `external` flag indicates whether `mod` has been externally defined,
                +    # as is the case with root namespaces or reopened third-party namespaces.
                +    #
                +    #: (String, Module, external: boolish) -> void
                +    private def define_autoloads_for_dir(dir, mod, external:)
                @@ -480,9 +498,8 @@
                -          basename.delete_suffix!(".rb")
                -          cref = Cref.new(parent, cname_for(basename, abspath))
                -          autoload_file(cref, abspath)
                -        else
                -          if collapse?(abspath)
                -            define_autoloads_for_dir(abspath, parent)
                -          else
                -            cref = Cref.new(parent, cname_for(basename, abspath))
                -            autoload_subdir(cref, abspath)
                +          if basename == @nsfile
                +            if external
                +              cpath = real_mod_name(mod)
                +              location = Object.const_source_location(cpath)&.join(':')
                +              location = nil if location&.empty?
                +              raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
                +            end
                +            next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
                @@ -489,0 +507,7 @@
                +
                +          basename.delete_suffix!('.rb')
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_file(cref, abspath)
                +        else
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_subdir(cref, abspath, external:)
                @@ -495 +519,25 @@
                -    private def autoload_subdir(cref, subdir)
                +    private def visit_file(cref, file)
                +      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                +        if @fs.rb_extension?(autoload_path)
                +          if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
                +          end
                +          shadowed_files << file
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                +        else
                +          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                +        end
                +      elsif cref.defined?
                +        shadowed_files << file
                +        if location = cref.location
                +          log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
                +        else
                +          log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
                +        end
                +      else
                +        define_autoload(cref, file)
                +      end
                +    end
                +
                +    #: (Zeitwerk::Cref, String, external: boolish) -> void
                +    private def visit_subdir(cref, subdir, external:)
                @@ -497,0 +546,6 @@
                +          # The namespace that corresponds to this subdirectory is defined in a
                +          # file, either regular or nsfile. Therefore, a nsfile would be a
                +          # duplication.
                +          if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
                +          end
                @@ -499,2 +553 @@
                -          # constant has been found. This means we are dealing with an explicit
                -          # namespace whose definition was seen first.
                +          # constant has been found. This is an explicit namespace.
                @@ -502,3 +555,2 @@
                -          # Registering is idempotent, and we have to keep the autoload pointing
                -          # to the file. This may run again if more directories are found later
                -          # on, no big deal.
                +          # The namespace may be spread over multiple directories and perhaps it
                +          # was already registered, but registering is idempotent, just do it.
                @@ -505,0 +558,3 @@
                +        elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          # Scanning found a matching directory first, and now we saw a nsfile.
                +          promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
                @@ -507,3 +561,0 @@
                -        # If the existing autoload points to a file, it has to be preserved, if
                -        # not, it is fine as it is. In either case, we do not need to override.
                -        # Just remember the subdirectory conforms this namespace.
                @@ -512 +564,6 @@
                -        # First time we find this namespace, set an autoload for it.
                +        if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          define_autoload(cref, nsfile_abspath)
                +          register_explicit_namespace(cref)
                +        else
                +          define_autoload(cref, subdir)
                +        end
                @@ -514 +570,0 @@
                -        define_autoload(cref, subdir)
                @@ -519,19 +575 @@
                -        define_autoloads_for_dir(subdir, cref.get)
                -      end
                -    end
                -
                -    #: (Zeitwerk::Cref, String) -> void
                -    private def autoload_file(cref, file)
                -      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                -        # First autoload for a Ruby file wins, just ignore subsequent ones.
                -        if @fs.rb_extension?(autoload_path)
                -          shadowed_files << file
                -          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                -        else
                -          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                -        end
                -      elsif cref.defined?
                -        shadowed_files << file
                -        log { "file #{file} is ignored because #{cref} is already defined" }
                -      else
                -        define_autoload(cref, file)
                +        define_autoloads_for_dir(subdir, cref.get, external:)
                @@ -611 +649 @@
                -        require "pp" # Needed to have pretty_inspect available.
                +        require 'pp' # Needed to have pretty_inspect available.
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:27.389262933 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:27.399262833 +0000
                @@ -80 +80 @@
                -        define_autoloads_for_dir(dir, namespace)
                +        define_autoloads_for_dir(dir, namespace, external: false)
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:27.389262933 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:27.399262833 +0000
                @@ -3,2 +3,2 @@
                -require "set"
                -require "securerandom"
                +require 'set'
                +require 'securerandom'
                @@ -18,2 +18,2 @@
                -  #   "/Users/fxn/blog/app/channels" => Object,
                -  #   "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
                +  #   '/Users/fxn/blog/app/channels' => Object,
                +  #   '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
                @@ -31,0 +32,6 @@
                +  # Basename of files that define namespaces. For example, if `nsfile` is
                +  # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
                +  #
                +  #: String?
                +  attr_reader :nsfile
                +
                @@ -53 +59 @@
                -  # glob patterns were expanded. Computed on setup, and recomputed on reload.
                +  # glob patterns were expanded. Computed on setup and recomputed on reload.
                @@ -58,0 +65,8 @@
                +  # Absolute paths of directories that are parents of collapsed directories.
                +  # This is a cache to optimize some tree walks. Computed on setup and
                +  # recomputed on reload.
                +  #
                +  #: Set[String]
                +  attr_reader :collapse_parents
                +  private :collapse_parents
                +
                @@ -84,0 +99 @@
                +  #: () -> void
                @@ -90,0 +106 @@
                +    @nsfile                 = nil
                @@ -94,0 +111 @@
                +    @collapse_parents       = Set.new
                @@ -115 +132 @@
                -      raise Zeitwerk::Error, "root namespaces cannot be anonymous"
                +      raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
                @@ -143,0 +161,12 @@
                +  #: (String?) -> void ! TypeError, ArgumentError
                +  def nsfile=(nsfile)
                +    unless nsfile.nil?
                +      raise TypeError,     'nsfiles must be strings'              unless nsfile.is_a?(String)
                +      raise ArgumentError, 'nsfiles must have .rb extension'      unless @fs.rb_extension?(nsfile)
                +      raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
                +      raise ArgumentError, 'nsfiles cannot be hidden'             if @fs.hidden?(nsfile)
                +    end
                +
                +    @nsfile = nsfile
                +  end
                +
                @@ -179 +208 @@
                -        raise Zeitwerk::Error, "cannot enable reloading after setup"
                +        raise Zeitwerk::Error, 'cannot enable reloading after setup'
                @@ -217 +246,5 @@
                -      collapse_dirs.merge(expand_glob_patterns(glob_patterns))
                +      new_collapse_dirs = expand_glob_patterns(glob_patterns)
                +      collapse_dirs.merge(new_collapse_dirs)
                +      new_collapse_dirs.each do |dir|
                +        collapse_parents << File.dirname(dir)
                +      end
                @@ -236,2 +269,2 @@
                -  #   loader.on_load("SomeApiClient") do |klass, _abspath|
                -  #     klass.endpoint = "https://api.dev"
                +  #   loader.on_load('SomeApiClient') do |klass, _abspath|
                +  #     klass.endpoint = 'https://api.dev'
                @@ -248 +281 @@
                -    raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -259 +292 @@
                -  #   loader.on_unload("Country") do |klass, _abspath|
                +  #   loader.on_unload('Country') do |klass, _abspath|
                @@ -271 +304 @@
                -    raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -318,0 +352,10 @@
                +  internal def collapse?(dir)
                +    collapse_dirs.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                +  internal def collapse_parent?(dir)
                +    collapse_parents.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                @@ -331,5 +373,0 @@
                -  #: (String) -> bool
                -  private def collapse?(dir)
                -    collapse_dirs.member?(dir)
                -  end
                -
                @@ -355,0 +394,8 @@
                +  end
                +
                +  #: () -> void
                +  private def recompute_collapse_parents
                +    collapse_parents.clear
                +    collapse_dirs.each do |dir|
                +      collapse_parents << File.dirname(dir)
                +    end
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:27.389262933 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:27.399262833 +0000
                @@ -3,4 +3,5 @@
                -  # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
                -  # shadowed files are not eager loaded. You can opt-out specifically in
                -  # specific files and directories with `do_not_eager_load`, and that can be
                -  # overridden passing `force: true`.
                +  # need to be in `$LOAD_PATH`, absolute file names are used.
                +  #
                +  # Ignored files are not eager loaded. You can opt-out specifically in specific
                +  # files and directories with `do_not_eager_load`, and that can be overridden
                +  # passing `force: true`.
                @@ -14 +15 @@
                -      log { "eager load start" }
                +      log { 'eager load start' }
                @@ -27 +28 @@
                -      log { "eager load end" }
                +      log { 'eager load end' }
                @@ -94 +95 @@
                -        if root_namespace_name.start_with?(mod_name + "::")
                +        if root_namespace_name.start_with?(mod_name + '::')
                @@ -98 +99 @@
                -        elsif mod_name.start_with?(root_namespace_name + "::")
                +        elsif mod_name.start_with?(root_namespace_name + '::')
                @@ -122 +123 @@
                -    file_basename = File.basename(abspath, ".rb")
                +    file_basename = File.basename(abspath)
                @@ -141,2 +141,0 @@
                -    base_cname = cname_for(file_basename, abspath)
                -
                @@ -149,3 +148,8 @@
                -    raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
                -
                -    namespace.const_get(base_cname, false)
                +    if file_basename == @nsfile
                +      namespace
                +    elsif shadowed_file?(abspath)
                +      raise Zeitwerk::Error.new("#{abspath} is shadowed")
                +    else
                +      cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
                +      namespace.const_get(cname, false)
                +    end
                @@ -174,6 +178,2 @@
                -          if collapse?(abspath)
                -            queue << [abspath, namespace]
                -          else
                -            cname = cname_for(basename, abspath)
                -            queue << [abspath, namespace.const_get(cname, false)]
                -          end
                +          cname = cname_for(basename, abspath)
                +          queue << [abspath, namespace.const_get(cname, false)]
                @@ -194 +194 @@
                -      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
                +      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
                @@ -207 +207 @@
                -    suffix.split("::").each do |segment|
                +    suffix.split('::').each do |segment|
                @@ -210,5 +210 @@
                -          next unless ftype == :directory
                -
                -          if collapse?(abspath)
                -            dirs << abspath
                -          elsif segment == cname_for(basename, abspath).to_s
                +          if ftype == :directory && segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:27.391262913 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:27.399262833 +0000
                @@ -14,0 +15,14 @@
                +  # This method lists directories, filtering out the following:
                +  #
                +  # - Hidden entries.
                +  # - Ignored entries.
                +  # - Files whose extension is not `.rb`.
                +  # - Nested root directories, since they represent separate trees.
                +  # - Subdirectories that (recursively) contain no Ruby files.
                +  #
                +  # If `collapse` is true, collapsed directories are not yielded, instead, the
                +  # method recurses so that the caller gets a conceptually flat listing.
                +  #
                +  # For every entry that is not excluded, `ls` yields its basename, absolute
                +  # path, and file type, which can only be :file or :directory.
                +  #
                @@ -16 +30 @@
                -  def ls(dir)
                +  def ls(dir, collapse: true, &block)
                @@ -27,3 +41,8 @@
                -      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                -        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                -        next
                +      if ftype == :directory
                +        if !has_at_least_one_ruby_file?(abspath)
                +          @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +          next
                +        elsif collapse && @loader.__collapse?(abspath)
                +          ls(abspath, collapse: collapse, &block)
                +          next
                +        end
                @@ -41 +60,38 @@
                -      break if basename == "/"
                +      break if basename == '/'
                +    end
                +  end
                +
                +  # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
                +  # there is none, it returns `nil`.
                +  #
                +  # This method accounts for collapsed directories, which conceptually allow for
                +  # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
                +  #
                +  #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
                +  def has_exactly_one_nsfile?(cref, dir)
                +    return unless @loader.nsfile
                +
                +    # When `dir` does not have any collapsed directories a simple lookup
                +    # suffices. This is a common case worth optimizing.
                +    unless @loader.__collapse_parent?(dir)
                +      nsfile_abspath = File.join(dir, @loader.nsfile)
                +      if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
                +        return nsfile_abspath
                +      end
                +      return
                +    end
                +
                +    nsfile = nil
                +
                +    to_visit = [dir]
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |basename, abspath, ftype|
                +        if ftype == :file && basename == @loader.nsfile
                +          if nsfile
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
                +          end
                +          nsfile = abspath
                +        elsif ftype == :directory && @loader.__collapse?(abspath)
                +          to_visit << abspath
                +        end
                +      end
                @@ -42,0 +99,2 @@
                +
                +    nsfile
                @@ -58 +116 @@
                -    path.end_with?(".rb")
                +    path.end_with?('.rb')
                @@ -68 +126 @@
                -    basename.start_with?(".")
                +    basename.start_with?('.')
                @@ -83 +141 @@
                -        return true if :file == ftype
                +        return true if ftype == :file
                @@ -99,12 +157,3 @@
                -      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
                +      if ftype == :file
                +        yield basename, abspath, ftype
                +      else
                @@ -138 +187 @@
                -        elsif :directory == ftype
                +        elsif ftype == :directory
                @@ -141 +190 @@
                -        elsif :link == ftype
                +        elsif ftype == :link
                @@ -158,3 +207 @@
                -          if dir?(abspath)
                -            yield basename, abspath, :directory
                -          end
                +          yield basename, abspath, :directory if dir?(abspath)
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:27.391262913 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:27.399262833 +0000
                @@ -15 +15 @@
                -    if cname.include?("::")
                +    if cname.include?('::')
                @@ -28 +28 @@
                -      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
        lib/zeitwerk/registry.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-05-20 07:03:27.391262913 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/registry.rb	2026-05-20 07:03:27.400262823 +0000
                @@ -5,4 +5,4 @@
                -    require_relative "registry/autoloads"
                -    require_relative "registry/explicit_namespaces"
                -    require_relative "registry/inceptions"
                -    require_relative "registry/loaders"
                +    require_relative 'registry/autoloads'
                +    require_relative 'registry/explicit_namespaces'
                +    require_relative 'registry/inceptions'
                +    require_relative 'registry/loaders'
                @@ -57,2 +57,2 @@
                -              new_root_dir_slash = new_root_dir + "/"
                -              existing_root_dir_slash = existing_root_dir + "/"
                +              new_root_dir_slash = new_root_dir + '/'
                +              existing_root_dir_slash = existing_root_dir + '/'
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:27.393262893 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:27.400262823 +0000
                @@ -9,2 +9,2 @@
                -    def each(&)
                -      @loaders.each(&)
                +    def each(&block)
                +      @loaders.each(&block)
        lib/zeitwerk/version.rb
                --- /tmp/d20260520-396-eo3ax9/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-05-20 07:03:27.393262893 +0000
                +++ /tmp/d20260520-396-eo3ax9/zeitwerk-2.8.1/lib/zeitwerk/version.rb	2026-05-20 07:03:27.400262823 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.5"
                +  VERSION = '2.8.1'

@github-actions

Copy link
Copy Markdown
Contributor

gem compare --diff zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
        README.md
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/README.md	2026-05-20 07:03:31.418318124 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/README.md	2026-05-20 07:03:31.424318167 +0000
                @@ -21,0 +22,2 @@
                +    - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
                +    - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
                @@ -101 +103 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -184 +186 @@
                -loader.inflector.inflect("max_retries" => "MAX_RETRIES")
                +loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
                @@ -223,2 +225,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -267 +269,6 @@
                -Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
                +Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
                +
                +<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
                +#### Explicit namespaces defined in ordinary files
                +
                +Let's consider:
                @@ -274 +281 @@
                -There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
                +Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
                @@ -276 +283,3 @@
                -The classes and modules from the namespace are already available in the body of the class or module defining it:
                +In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
                +
                +The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
                @@ -287 +296,42 @@
                -An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
                +<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
                +#### Explicit namespaces defined in nsfiles
                +
                +If the loader has an nsfile configured (defaults to `nil`):
                +
                +```ruby
                +loader.nsfile = 'ns.rb' # must be set before setup
                +```
                +
                +you can alternatively define the explicit namespace inside its directory:
                +
                +```
                +my_component/ns.rb     -> MyComponent
                +my_component/widget.rb -> MyComponent::Widget
                +```
                +
                +This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
                +
                +A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
                +
                +Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
                +
                +```
                +my_component/src/ns.rb            -> MyComponent
                +my_component/src/widget.rb        -> MyComponent::Widget
                +my_component/assets/widget.js
                +my_component/tests/test_widget.rb
                +```
                +
                +Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
                +
                +However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
                +
                +Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
                +
                +Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
                +
                +```ruby
                +loader.nsfile = 'index.rb'
                +```
                +
                +there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
                @@ -375 +425 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -377 +427 @@
                -loader.tag = File.basename(__FILE__, ".rb")
                +loader.tag = File.basename(__FILE__, '.rb')
                @@ -387 +437 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -433 +483 @@
                -gem "net-http-niche_feature"
                +gem 'net-http-niche_feature'
                @@ -446 +496 @@
                -require "net/http/niche_feature"
                +require 'net/http/niche_feature'
                @@ -451,2 +501,2 @@
                -require "net/http"
                -require "zeitwerk"
                +require 'net/http'
                +require 'zeitwerk'
                @@ -467 +517 @@
                -  VERSION = "1.0.0"
                +  VERSION = '1.0.0'
                @@ -489 +539 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -498 +548 @@
                -That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                +That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                @@ -685 +735 @@
                -require "concurrent/atomic/read_write_lock"
                +require 'concurrent/atomic/read_write_lock'
                @@ -730,2 +780,2 @@
                -  "html_parser"   => "HTMLParser",
                -  "mysql_adapter" => "MySQLAdapter"
                +  'html_parser'   => 'HTMLParser',
                +  'mysql_adapter' => 'MySQLAdapter'
                @@ -738,2 +788,2 @@
                -loader.inflector.inflect "html_parser" => "HTMLParser"
                -loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
                +loader.inflector.inflect 'html_parser' => 'HTMLParser'
                +loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
                @@ -745 +795 @@
                -loader.inflector.inflect("xml" => "XML")
                +loader.inflector.inflect('xml' => 'XML')
                @@ -760,2 +810,2 @@
                -  "xml"        => "XML",
                -  "xml_parser" => "XMLParser"
                +  'xml'        => 'XML',
                +  'xml_parser' => 'XMLParser'
                @@ -816 +866 @@
                -      "HTML" + super($1, abspath)
                +      'HTML' + super($1, abspath)
                @@ -847,2 +897,2 @@
                -require "zeitwerk"
                -require_relative "my_gem/inflector"
                +require 'zeitwerk'
                +require_relative 'my_gem/inflector'
                @@ -916,2 +966,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.dev"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.dev'
                @@ -921,2 +971,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.prod"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.prod'
                @@ -966 +1016 @@
                -loader.on_unload("Country") do |klass, _abspath|
                +loader.on_unload('Country') do |klass, _abspath|
                @@ -1051 +1101 @@
                -loader.tag = "grep_me"
                +loader.tag = 'grep_me'
                @@ -1107 +1157 @@
                -require_relative "my_gem/core_ext/kernel"
                +require_relative 'my_gem/core_ext/kernel'
                @@ -1121 +1171 @@
                -require "pg"
                +require 'pg'
                @@ -1159 +1209 @@
                -require "foo"
                +require 'foo'
                @@ -1228,2 +1278,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -1231 +1281 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -1250,2 +1300,2 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.dirs # => ["/foo"]
                +loader.push_dir(Pathname.new('/foo'))
                +loader.dirs # => ['/foo']
                @@ -1258,3 +1308,3 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.push_dir(Pathname.new("/bar"), namespace: Bar)
                -loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
                +loader.push_dir(Pathname.new('/foo'))
                +loader.push_dir(Pathname.new('/bar'), namespace: Bar)
                +loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
                @@ -1287,4 +1337,4 @@
                -loader.cpath_expected_at("app/models")                  # => "Object"
                -loader.cpath_expected_at("app/models/user.rb")          # => "User"
                -loader.cpath_expected_at("app/models/hotel")            # => "Hotel"
                -loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
                +loader.cpath_expected_at('app/models')                  # => 'Object'
                +loader.cpath_expected_at('app/models/user.rb')          # => 'User'
                +loader.cpath_expected_at('app/models/hotel')            # => 'Hotel'
                +loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
                @@ -1296,3 +1346,3 @@
                -loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
                -loader.cpath_expected_at("a/b/collapsed")   # => "A::B", edge case
                -loader.cpath_expected_at("a/b")             # => "A::B"
                +loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
                +loader.cpath_expected_at('a/b/collapsed')   # => 'A::B', edge case
                +loader.cpath_expected_at('a/b')             # => 'A::B'
                @@ -1306 +1356 @@
                -loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
                +loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
                @@ -1312 +1362 @@
                -loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
                +loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
                @@ -1315 +1365,3 @@
                -This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1332 +1383,0 @@
                -lib/my_gem/ignored.rb
                @@ -1335,0 +1387 @@
                +lib/my_gem/ignored.rb
                @@ -1343,9 +1395,9 @@
                -  "/.../lib"                           => "Object",
                -  "/.../lib/my_gem.rb"                 => "MyGem",
                -  "/.../lib/my_gem"                    => "MyGem",
                -  "/.../lib/my_gem/version.rb"         => "MyGem::VERSION",
                -  "/.../lib/my_gem/drivers"            => "MyGem::Drivers",
                -  "/.../lib/my_gem/drivers/unix.rb"    => "MyGem::Drivers::Unix",
                -  "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
                -  "/.../lib/my_gem/collapsed"          => "MyGem",
                -  "/.../lib/my_gem/collapsed/foo.rb"   => "MyGem::Foo"
                +  '/.../lib'                           => 'Object',
                +  '/.../lib/my_gem.rb'                 => 'MyGem',
                +  '/.../lib/my_gem'                    => 'MyGem',
                +  '/.../lib/my_gem/version.rb'         => 'MyGem::VERSION',
                +  '/.../lib/my_gem/drivers'            => 'MyGem::Drivers',
                +  '/.../lib/my_gem/drivers/unix.rb'    => 'MyGem::Drivers::Unix',
                +  '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
                +  '/.../lib/my_gem/collapsed'          => 'MyGem',
                +  '/.../lib/my_gem/collapsed/foo.rb'   => 'MyGem::Foo'
                @@ -1357 +1409 @@
                -The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
                +The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
                @@ -1363 +1415,3 @@
                -This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1434,2 +1488,2 @@
                -test "capitalizes the first letter" do
                -  assert_equal "User", camelize("user")
                +test 'capitalizes the first letter' do
                +  assert_equal 'User', camelize('user')
                @@ -1449 +1503 @@
                -Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
                +Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
        lib/zeitwerk.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk.rb	2026-05-20 07:03:31.418318124 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk.rb	2026-05-20 07:03:31.424318167 +0000
                @@ -4,11 +4,11 @@
                -  require_relative "zeitwerk/real_mod_name"
                -  require_relative "zeitwerk/internal"
                -  require_relative "zeitwerk/cref"
                -  require_relative "zeitwerk/loader"
                -  require_relative "zeitwerk/gem_loader"
                -  require_relative "zeitwerk/registry"
                -  require_relative "zeitwerk/inflector"
                -  require_relative "zeitwerk/gem_inflector"
                -  require_relative "zeitwerk/null_inflector"
                -  require_relative "zeitwerk/error"
                -  require_relative "zeitwerk/version"
                +  require_relative 'zeitwerk/real_mod_name'
                +  require_relative 'zeitwerk/internal'
                +  require_relative 'zeitwerk/cref'
                +  require_relative 'zeitwerk/loader'
                +  require_relative 'zeitwerk/gem_loader'
                +  require_relative 'zeitwerk/registry'
                +  require_relative 'zeitwerk/inflector'
                +  require_relative 'zeitwerk/gem_inflector'
                +  require_relative 'zeitwerk/null_inflector'
                +  require_relative 'zeitwerk/error'
                +  require_relative 'zeitwerk/version'
                @@ -16,2 +16,2 @@
                -  require_relative "zeitwerk/core_ext/kernel"
                -  require_relative "zeitwerk/core_ext/module"
                +  require_relative 'zeitwerk/core_ext/kernel'
                +  require_relative 'zeitwerk/core_ext/module'
        lib/zeitwerk/core_ext/kernel.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:31.418318124 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:31.424318167 +0000
                @@ -25 +25 @@
                -      if path.end_with?(".rb")
                +      if path.end_with?('.rb')
        lib/zeitwerk/cref.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/cref.rb	2026-05-20 07:03:31.419318131 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/cref.rb	2026-05-20 07:03:31.425318174 +0000
                @@ -14 +14 @@
                -  require_relative "cref/map"
                +  require_relative 'cref/map'
                @@ -67,0 +68,7 @@
                +  end
                +
                +  #: () -> String?
                +  def location
                +    if (location = @mod.const_source_location(@cname)) && !location.empty?
                +      location.join(':')
                +    end
        lib/zeitwerk/cref/map.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:31.419318131 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:31.425318174 +0000
                @@ -30 +30 @@
                -#      { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
                +#      { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
        lib/zeitwerk/error.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/error.rb	2026-05-20 07:03:31.419318131 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/error.rb	2026-05-20 07:03:31.425318174 +0000
                @@ -20 +20,12 @@
                -      super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
                +      super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
                +    end
                +  end
                +
                +  class ConflictingNamespaceDefinitionError < Error
                +    #: (String, location: String?, conflicting_file: String) -> void
                +    def initialize(cpath, location:, conflicting_file:)
                +      if location
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
                +      else
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
                +      end
        lib/zeitwerk/gem_inflector.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:31.419318131 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:31.425318174 +0000
                @@ -7 +7 @@
                -      namespace     = File.basename(root_file, ".rb")
                +      namespace     = File.basename(root_file, '.rb')
                @@ -9 +9 @@
                -      @version_file = File.join(root_dir, namespace, "version.rb")
                +      @version_file = File.join(root_dir, namespace, 'version.rb')
                @@ -14 +14 @@
                -      abspath == @version_file ? "VERSION" : super
                +      abspath == @version_file ? 'VERSION' : super
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:31.420318139 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:31.426318182 +0000
                @@ -22,2 +22,2 @@
                -      @tag = File.basename(root_file, ".rb")
                -      @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
                +      @tag = File.basename(root_file, '.rb')
                +      @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
                @@ -43 +43 @@
                -      expected_namespace_dir = @root_file.delete_suffix(".rb")
                +      expected_namespace_dir = @root_file.delete_suffix('.rb')
                @@ -49 +49 @@
                -        basename_without_ext = basename.delete_suffix(".rb")
                +        basename_without_ext = basename.delete_suffix('.rb')
        lib/zeitwerk/inflector.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb	2026-05-20 07:03:31.420318139 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb	2026-05-20 07:03:31.427318189 +0000
                @@ -8,3 +8,3 @@
                -    #   inflector.camelize("post", ...)             # => "Post"
                -    #   inflector.camelize("users_controller", ...) # => "UsersController"
                -    #   inflector.camelize("api", ...)              # => "Api"
                +    #   inflector.camelize('post', ...)             # => 'Post'
                +    #   inflector.camelize('users_controller', ...) # => 'UsersController'
                +    #   inflector.camelize('api', ...)              # => 'Api'
                @@ -23,2 +23,2 @@
                -    #     "html_parser"   => "HTMLParser",
                -    #     "mysql_adapter" => "MySQLAdapter"
                +    #     'html_parser'   => 'HTMLParser',
                +    #     'mysql_adapter' => 'MySQLAdapter'
                @@ -27,3 +27,3 @@
                -    #   inflector.camelize("html_parser", abspath)      # => "HTMLParser"
                -    #   inflector.camelize("mysql_adapter", abspath)    # => "MySQLAdapter"
                -    #   inflector.camelize("users_controller", abspath) # => "UsersController"
                +    #   inflector.camelize('html_parser', abspath)      # => 'HTMLParser'
                +    #   inflector.camelize('mysql_adapter', abspath)    # => 'MySQLAdapter'
                +    #   inflector.camelize('users_controller', abspath) # => 'UsersController'
        lib/zeitwerk/loader.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-05-20 07:03:31.420318139 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader.rb	2026-05-20 07:03:31.427318189 +0000
                @@ -3,2 +3,2 @@
                -require "monitor"
                -require "set"
                +require 'monitor'
                +require 'set'
                @@ -8,5 +8,5 @@
                -    require_relative "loader/helpers"
                -    require_relative "loader/callbacks"
                -    require_relative "loader/config"
                -    require_relative "loader/eager_load"
                -    require_relative "loader/file_system"
                +    require_relative 'loader/helpers'
                +    require_relative 'loader/callbacks'
                +    require_relative 'loader/config'
                +    require_relative 'loader/eager_load'
                +    require_relative 'loader/file_system'
                @@ -25,2 +25,2 @@
                -    #   "/Users/fxn/blog/app/models/user.rb"          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                -    #   "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                +    #   '/Users/fxn/blog/app/models/user.rb'          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                +    #   '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                @@ -104,0 +105 @@
                +    #: () -> void
                @@ -132 +133 @@
                -          define_autoloads_for_dir(root_dir, root_namespace)
                +          define_autoloads_for_dir(root_dir, root_namespace, external: true)
                @@ -155,0 +157,3 @@
                +        __unload
                +      end
                +    end
                @@ -157,33 +161,19 @@
                -        # We are going to keep track of the files that were required by our
                -        # autoloads to later remove them from $LOADED_FEATURES, thus making them
                -        # loadable by Kernel#require again.
                -        #
                -        # Directories are not stored in $LOADED_FEATURES, keeping track of files
                -        # is enough.
                -        unloaded_files = Set.new
                -
                -        autoloads.each do |abspath, cref|
                -          if cref.autoload?
                -            unload_autoload(cref)
                -          else
                -            # Could happen if loaded with require_relative. That is unsupported,
                -            # and the constant path would escape unloadable_cpath? This is just
                -            # defensive code to clean things up as much as we are able to.
                -            unload_cref(cref)
                -            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                -          end
                -        end
                -
                -        to_unload.each do |abspath, cref|
                -          unless on_unload_callbacks.empty?
                -            begin
                -              value = cref.get
                -            rescue ::NameError
                -              # Perhaps the user deleted the constant by hand, or perhaps an
                -              # autoload failed to define the expected constant but the user
                -              # rescued the exception.
                -            else
                -              run_on_unload_callbacks(cref, value, abspath)
                -            end
                -          end
                -
                +    # This is an internal method.
                +    #
                +    #: () -> void
                +    def __unload
                +      # We are going to keep track of the files that were required by our
                +      # autoloads to later remove them from $LOADED_FEATURES, thus making them
                +      # loadable by Kernel#require again.
                +      #
                +      # Directories are not stored in $LOADED_FEATURES, keeping track of files
                +      # is enough.
                +      unloaded_files = Set.new
                +
                +      autoloads.each do |abspath, cref|
                +        if cref.autoload?
                +          unload_autoload(cref)
                +        else
                +          # Could happen if loaded with require_relative. That is unsupported,
                +          # and the constant path would escape unloadable_cpath? This is just
                +          # defensive code to clean things up as much as we are able to.
                @@ -192,0 +183 @@
                +      end
                @@ -194,13 +185,11 @@
                -        unless unloaded_files.empty?
                -          # Bootsnap decorates Kernel#require to speed it up using a cache and
                -          # this optimization does not check if $LOADED_FEATURES has the file.
                -          #
                -          # To make it aware of changes, the gem defines singleton methods in
                -          # $LOADED_FEATURES:
                -          #
                -          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                -          #
                -          # Rails applications may depend on bootsnap, so for unloading to work
                -          # in that setting it is preferable that we restrict our API choice to
                -          # one of those methods.
                -          $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      to_unload.each do |abspath, cref|
                +        unless on_unload_callbacks.empty?
                +          begin
                +            value = cref.get
                +          rescue ::NameError
                +            # Perhaps the user deleted the constant by hand, or perhaps an
                +            # autoload failed to define the expected constant but the user
                +            # rescued the exception.
                +          else
                +            run_on_unload_callbacks(cref, value, abspath)
                +          end
                @@ -209,5 +198,3 @@
                -        autoloads.clear
                -        autoloaded_dirs.clear
                -        to_unload.clear
                -        namespace_dirs.clear
                -        shadowed_files.clear
                +        unload_cref(cref)
                +        unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                +      end
                @@ -215,2 +202,14 @@
                -        unregister_inceptions
                -        unregister_explicit_namespaces
                +      unless unloaded_files.empty?
                +        # Bootsnap decorates Kernel#require to speed it up using a cache and
                +        # this optimization does not check if $LOADED_FEATURES has the file.
                +        #
                +        # To make it aware of changes, the gem defines singleton methods in
                +        # $LOADED_FEATURES:
                +        #
                +        #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +        #
                +        # Rails applications may depend on bootsnap, so for unloading to work
                +        # in that setting it is preferable that we restrict our API choice to
                +        # one of those methods.
                +        $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      end
                @@ -218 +217,5 @@
                -        Registry.autoloads.unregister_loader(self)
                +      autoloads.clear
                +      autoloaded_dirs.clear
                +      to_unload.clear
                +      namespace_dirs.clear
                +      shadowed_files.clear
                @@ -220,3 +223,7 @@
                -        @setup        = false
                -        @eager_loaded = false
                -      end
                +      unregister_inceptions
                +      unregister_explicit_namespaces
                +
                +      Registry.autoloads.unregister_loader(self)
                +
                +      @setup        = false
                +      @eager_loaded = false
                @@ -236,0 +244 @@
                +
                @@ -238,0 +247,2 @@
                +      recompute_collapse_parents
                +
                @@ -255 +265 @@
                -          prefix = cpath == "Object" ? "" : cpath + "::"
                +          prefix = cpath == 'Object' ? '' : cpath + '::'
                @@ -257 +267 @@
                -          @fs.ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
                @@ -259,5 +269,2 @@
                -              basename.delete_suffix!(".rb")
                -              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                -            else
                -              if collapse?(abspath)
                -                queue << [abspath, cpath]
                +              if basename == @nsfile
                +                result[abspath] = cpath
                @@ -265 +272,2 @@
                -                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                +                basename.delete_suffix!('.rb')
                +                result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266,0 +275,4 @@
                +            elsif collapse?(abspath)
                +              queue.unshift([abspath, cpath])
                +            else
                +              queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
                @@ -288,2 +300,2 @@
                -      if :file == ftype
                -        basename = File.basename(abspath, ".rb")
                +      if ftype == :file
                +        basename = File.basename(abspath)
                @@ -292 +304 @@
                -        paths << [basename, abspath]
                +        paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
                @@ -318 +330 @@
                -          cnames.join("::")
                +          cnames.join('::')
                @@ -320 +332 @@
                -          "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
                +          "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
                @@ -387 +399 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -390 +402 @@
                -      #   loader.tag = File.basename(__FILE__, ".rb")
                +      #   loader.tag = File.basename(__FILE__, '.rb')
                @@ -408 +420 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -411 +423 @@
                -      #   loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
                +      #   loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
                @@ -428 +440 @@
                -          raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
                +          raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
                @@ -476,2 +488,8 @@
                -    #: (String, Module) -> void
                -    private def define_autoloads_for_dir(dir, parent)
                +    # Scans `dir` and sets autoloads in `mod` for the constants its contents are
                +    # expected to define.
                +    #
                +    # The `external` flag indicates whether `mod` has been externally defined,
                +    # as is the case with root namespaces or reopened third-party namespaces.
                +    #
                +    #: (String, Module, external: boolish) -> void
                +    private def define_autoloads_for_dir(dir, mod, external:)
                @@ -480,9 +498,8 @@
                -          basename.delete_suffix!(".rb")
                -          cref = Cref.new(parent, cname_for(basename, abspath))
                -          autoload_file(cref, abspath)
                -        else
                -          if collapse?(abspath)
                -            define_autoloads_for_dir(abspath, parent)
                -          else
                -            cref = Cref.new(parent, cname_for(basename, abspath))
                -            autoload_subdir(cref, abspath)
                +          if basename == @nsfile
                +            if external
                +              cpath = real_mod_name(mod)
                +              location = Object.const_source_location(cpath)&.join(':')
                +              location = nil if location&.empty?
                +              raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
                +            end
                +            next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
                @@ -489,0 +507,7 @@
                +
                +          basename.delete_suffix!('.rb')
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_file(cref, abspath)
                +        else
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_subdir(cref, abspath, external:)
                @@ -495 +519,25 @@
                -    private def autoload_subdir(cref, subdir)
                +    private def visit_file(cref, file)
                +      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                +        if @fs.rb_extension?(autoload_path)
                +          if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
                +          end
                +          shadowed_files << file
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                +        else
                +          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                +        end
                +      elsif cref.defined?
                +        shadowed_files << file
                +        if location = cref.location
                +          log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
                +        else
                +          log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
                +        end
                +      else
                +        define_autoload(cref, file)
                +      end
                +    end
                +
                +    #: (Zeitwerk::Cref, String, external: boolish) -> void
                +    private def visit_subdir(cref, subdir, external:)
                @@ -497,0 +546,6 @@
                +          # The namespace that corresponds to this subdirectory is defined in a
                +          # file, either regular or nsfile. Therefore, a nsfile would be a
                +          # duplication.
                +          if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
                +          end
                @@ -499,2 +553 @@
                -          # constant has been found. This means we are dealing with an explicit
                -          # namespace whose definition was seen first.
                +          # constant has been found. This is an explicit namespace.
                @@ -502,3 +555,2 @@
                -          # Registering is idempotent, and we have to keep the autoload pointing
                -          # to the file. This may run again if more directories are found later
                -          # on, no big deal.
                +          # The namespace may be spread over multiple directories and perhaps it
                +          # was already registered, but registering is idempotent, just do it.
                @@ -505,0 +558,3 @@
                +        elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          # Scanning found a matching directory first, and now we saw a nsfile.
                +          promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
                @@ -507,3 +561,0 @@
                -        # If the existing autoload points to a file, it has to be preserved, if
                -        # not, it is fine as it is. In either case, we do not need to override.
                -        # Just remember the subdirectory conforms this namespace.
                @@ -512 +564,6 @@
                -        # First time we find this namespace, set an autoload for it.
                +        if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          define_autoload(cref, nsfile_abspath)
                +          register_explicit_namespace(cref)
                +        else
                +          define_autoload(cref, subdir)
                +        end
                @@ -514 +570,0 @@
                -        define_autoload(cref, subdir)
                @@ -519,19 +575 @@
                -        define_autoloads_for_dir(subdir, cref.get)
                -      end
                -    end
                -
                -    #: (Zeitwerk::Cref, String) -> void
                -    private def autoload_file(cref, file)
                -      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                -        # First autoload for a Ruby file wins, just ignore subsequent ones.
                -        if @fs.rb_extension?(autoload_path)
                -          shadowed_files << file
                -          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                -        else
                -          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                -        end
                -      elsif cref.defined?
                -        shadowed_files << file
                -        log { "file #{file} is ignored because #{cref} is already defined" }
                -      else
                -        define_autoload(cref, file)
                +        define_autoloads_for_dir(subdir, cref.get, external:)
                @@ -611 +649 @@
                -        require "pp" # Needed to have pretty_inspect available.
                +        require 'pp' # Needed to have pretty_inspect available.
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:31.420318139 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:31.427318189 +0000
                @@ -80 +80 @@
                -        define_autoloads_for_dir(dir, namespace)
                +        define_autoloads_for_dir(dir, namespace, external: false)
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:31.421318146 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:31.427318189 +0000
                @@ -3,2 +3,2 @@
                -require "set"
                -require "securerandom"
                +require 'set'
                +require 'securerandom'
                @@ -18,2 +18,2 @@
                -  #   "/Users/fxn/blog/app/channels" => Object,
                -  #   "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
                +  #   '/Users/fxn/blog/app/channels' => Object,
                +  #   '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
                @@ -31,0 +32,6 @@
                +  # Basename of files that define namespaces. For example, if `nsfile` is
                +  # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
                +  #
                +  #: String?
                +  attr_reader :nsfile
                +
                @@ -53 +59 @@
                -  # glob patterns were expanded. Computed on setup, and recomputed on reload.
                +  # glob patterns were expanded. Computed on setup and recomputed on reload.
                @@ -58,0 +65,8 @@
                +  # Absolute paths of directories that are parents of collapsed directories.
                +  # This is a cache to optimize some tree walks. Computed on setup and
                +  # recomputed on reload.
                +  #
                +  #: Set[String]
                +  attr_reader :collapse_parents
                +  private :collapse_parents
                +
                @@ -84,0 +99 @@
                +  #: () -> void
                @@ -90,0 +106 @@
                +    @nsfile                 = nil
                @@ -94,0 +111 @@
                +    @collapse_parents       = Set.new
                @@ -115 +132 @@
                -      raise Zeitwerk::Error, "root namespaces cannot be anonymous"
                +      raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
                @@ -143,0 +161,12 @@
                +  #: (String?) -> void ! TypeError, ArgumentError
                +  def nsfile=(nsfile)
                +    unless nsfile.nil?
                +      raise TypeError,     'nsfiles must be strings'              unless nsfile.is_a?(String)
                +      raise ArgumentError, 'nsfiles must have .rb extension'      unless @fs.rb_extension?(nsfile)
                +      raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
                +      raise ArgumentError, 'nsfiles cannot be hidden'             if @fs.hidden?(nsfile)
                +    end
                +
                +    @nsfile = nsfile
                +  end
                +
                @@ -179 +208 @@
                -        raise Zeitwerk::Error, "cannot enable reloading after setup"
                +        raise Zeitwerk::Error, 'cannot enable reloading after setup'
                @@ -217 +246,5 @@
                -      collapse_dirs.merge(expand_glob_patterns(glob_patterns))
                +      new_collapse_dirs = expand_glob_patterns(glob_patterns)
                +      collapse_dirs.merge(new_collapse_dirs)
                +      new_collapse_dirs.each do |dir|
                +        collapse_parents << File.dirname(dir)
                +      end
                @@ -236,2 +269,2 @@
                -  #   loader.on_load("SomeApiClient") do |klass, _abspath|
                -  #     klass.endpoint = "https://api.dev"
                +  #   loader.on_load('SomeApiClient') do |klass, _abspath|
                +  #     klass.endpoint = 'https://api.dev'
                @@ -248 +281 @@
                -    raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -259 +292 @@
                -  #   loader.on_unload("Country") do |klass, _abspath|
                +  #   loader.on_unload('Country') do |klass, _abspath|
                @@ -271 +304 @@
                -    raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -318,0 +352,10 @@
                +  internal def collapse?(dir)
                +    collapse_dirs.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                +  internal def collapse_parent?(dir)
                +    collapse_parents.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                @@ -331,5 +373,0 @@
                -  #: (String) -> bool
                -  private def collapse?(dir)
                -    collapse_dirs.member?(dir)
                -  end
                -
                @@ -355,0 +394,8 @@
                +  end
                +
                +  #: () -> void
                +  private def recompute_collapse_parents
                +    collapse_parents.clear
                +    collapse_dirs.each do |dir|
                +      collapse_parents << File.dirname(dir)
                +    end
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:31.421318146 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:31.428318196 +0000
                @@ -3,4 +3,5 @@
                -  # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
                -  # shadowed files are not eager loaded. You can opt-out specifically in
                -  # specific files and directories with `do_not_eager_load`, and that can be
                -  # overridden passing `force: true`.
                +  # need to be in `$LOAD_PATH`, absolute file names are used.
                +  #
                +  # Ignored files are not eager loaded. You can opt-out specifically in specific
                +  # files and directories with `do_not_eager_load`, and that can be overridden
                +  # passing `force: true`.
                @@ -14 +15 @@
                -      log { "eager load start" }
                +      log { 'eager load start' }
                @@ -27 +28 @@
                -      log { "eager load end" }
                +      log { 'eager load end' }
                @@ -94 +95 @@
                -        if root_namespace_name.start_with?(mod_name + "::")
                +        if root_namespace_name.start_with?(mod_name + '::')
                @@ -98 +99 @@
                -        elsif mod_name.start_with?(root_namespace_name + "::")
                +        elsif mod_name.start_with?(root_namespace_name + '::')
                @@ -122 +123 @@
                -    file_basename = File.basename(abspath, ".rb")
                +    file_basename = File.basename(abspath)
                @@ -141,2 +141,0 @@
                -    base_cname = cname_for(file_basename, abspath)
                -
                @@ -149,3 +148,8 @@
                -    raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
                -
                -    namespace.const_get(base_cname, false)
                +    if file_basename == @nsfile
                +      namespace
                +    elsif shadowed_file?(abspath)
                +      raise Zeitwerk::Error.new("#{abspath} is shadowed")
                +    else
                +      cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
                +      namespace.const_get(cname, false)
                +    end
                @@ -174,6 +178,2 @@
                -          if collapse?(abspath)
                -            queue << [abspath, namespace]
                -          else
                -            cname = cname_for(basename, abspath)
                -            queue << [abspath, namespace.const_get(cname, false)]
                -          end
                +          cname = cname_for(basename, abspath)
                +          queue << [abspath, namespace.const_get(cname, false)]
                @@ -194 +194 @@
                -      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
                +      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
                @@ -207 +207 @@
                -    suffix.split("::").each do |segment|
                +    suffix.split('::').each do |segment|
                @@ -210,5 +210 @@
                -          next unless ftype == :directory
                -
                -          if collapse?(abspath)
                -            dirs << abspath
                -          elsif segment == cname_for(basename, abspath).to_s
                +          if ftype == :directory && segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:31.421318146 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:31.428318196 +0000
                @@ -14,0 +15,14 @@
                +  # This method lists directories, filtering out the following:
                +  #
                +  # - Hidden entries.
                +  # - Ignored entries.
                +  # - Files whose extension is not `.rb`.
                +  # - Nested root directories, since they represent separate trees.
                +  # - Subdirectories that (recursively) contain no Ruby files.
                +  #
                +  # If `collapse` is true, collapsed directories are not yielded, instead, the
                +  # method recurses so that the caller gets a conceptually flat listing.
                +  #
                +  # For every entry that is not excluded, `ls` yields its basename, absolute
                +  # path, and file type, which can only be :file or :directory.
                +  #
                @@ -16 +30 @@
                -  def ls(dir)
                +  def ls(dir, collapse: true, &block)
                @@ -27,3 +41,8 @@
                -      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                -        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                -        next
                +      if ftype == :directory
                +        if !has_at_least_one_ruby_file?(abspath)
                +          @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +          next
                +        elsif collapse && @loader.__collapse?(abspath)
                +          ls(abspath, collapse: collapse, &block)
                +          next
                +        end
                @@ -41 +60,38 @@
                -      break if basename == "/"
                +      break if basename == '/'
                +    end
                +  end
                +
                +  # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
                +  # there is none, it returns `nil`.
                +  #
                +  # This method accounts for collapsed directories, which conceptually allow for
                +  # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
                +  #
                +  #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
                +  def has_exactly_one_nsfile?(cref, dir)
                +    return unless @loader.nsfile
                +
                +    # When `dir` does not have any collapsed directories a simple lookup
                +    # suffices. This is a common case worth optimizing.
                +    unless @loader.__collapse_parent?(dir)
                +      nsfile_abspath = File.join(dir, @loader.nsfile)
                +      if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
                +        return nsfile_abspath
                +      end
                +      return
                +    end
                +
                +    nsfile = nil
                +
                +    to_visit = [dir]
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |basename, abspath, ftype|
                +        if ftype == :file && basename == @loader.nsfile
                +          if nsfile
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
                +          end
                +          nsfile = abspath
                +        elsif ftype == :directory && @loader.__collapse?(abspath)
                +          to_visit << abspath
                +        end
                +      end
                @@ -42,0 +99,2 @@
                +
                +    nsfile
                @@ -58 +116 @@
                -    path.end_with?(".rb")
                +    path.end_with?('.rb')
                @@ -68 +126 @@
                -    basename.start_with?(".")
                +    basename.start_with?('.')
                @@ -83 +141 @@
                -        return true if :file == ftype
                +        return true if ftype == :file
                @@ -99,12 +157,3 @@
                -      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
                +      if ftype == :file
                +        yield basename, abspath, ftype
                +      else
                @@ -138 +187 @@
                -        elsif :directory == ftype
                +        elsif ftype == :directory
                @@ -141 +190 @@
                -        elsif :link == ftype
                +        elsif ftype == :link
                @@ -158,3 +207 @@
                -          if dir?(abspath)
                -            yield basename, abspath, :directory
                -          end
                +          yield basename, abspath, :directory if dir?(abspath)
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:31.421318146 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:31.428318196 +0000
                @@ -15 +15 @@
                -    if cname.include?("::")
                +    if cname.include?('::')
                @@ -28 +28 @@
                -      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
        lib/zeitwerk/registry.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-05-20 07:03:31.422318153 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/registry.rb	2026-05-20 07:03:31.428318196 +0000
                @@ -5,4 +5,4 @@
                -    require_relative "registry/autoloads"
                -    require_relative "registry/explicit_namespaces"
                -    require_relative "registry/inceptions"
                -    require_relative "registry/loaders"
                +    require_relative 'registry/autoloads'
                +    require_relative 'registry/explicit_namespaces'
                +    require_relative 'registry/inceptions'
                +    require_relative 'registry/loaders'
                @@ -57,2 +57,2 @@
                -              new_root_dir_slash = new_root_dir + "/"
                -              existing_root_dir_slash = existing_root_dir + "/"
                +              new_root_dir_slash = new_root_dir + '/'
                +              existing_root_dir_slash = existing_root_dir + '/'
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:31.422318153 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:31.429318203 +0000
                @@ -9,2 +9,2 @@
                -    def each(&)
                -      @loaders.each(&)
                +    def each(&block)
                +      @loaders.each(&block)
        lib/zeitwerk/version.rb
                --- /tmp/d20260520-397-kvis6z/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-05-20 07:03:31.422318153 +0000
                +++ /tmp/d20260520-397-kvis6z/zeitwerk-2.8.1/lib/zeitwerk/version.rb	2026-05-20 07:03:31.429318203 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.5"
                +  VERSION = '2.8.1'

@github-actions

Copy link
Copy Markdown
Contributor

gem compare --diff zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
        README.md
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/README.md	2026-05-20 07:03:32.837698897 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/README.md	2026-05-20 07:03:32.841698869 +0000
                @@ -21,0 +22,2 @@
                +    - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
                +    - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
                @@ -101 +103 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -184 +186 @@
                -loader.inflector.inflect("max_retries" => "MAX_RETRIES")
                +loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
                @@ -223,2 +225,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -267 +269,6 @@
                -Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
                +Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
                +
                +<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
                +#### Explicit namespaces defined in ordinary files
                +
                +Let's consider:
                @@ -274 +281 @@
                -There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
                +Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
                @@ -276 +283,3 @@
                -The classes and modules from the namespace are already available in the body of the class or module defining it:
                +In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
                +
                +The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
                @@ -287 +296,42 @@
                -An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
                +<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
                +#### Explicit namespaces defined in nsfiles
                +
                +If the loader has an nsfile configured (defaults to `nil`):
                +
                +```ruby
                +loader.nsfile = 'ns.rb' # must be set before setup
                +```
                +
                +you can alternatively define the explicit namespace inside its directory:
                +
                +```
                +my_component/ns.rb     -> MyComponent
                +my_component/widget.rb -> MyComponent::Widget
                +```
                +
                +This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
                +
                +A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
                +
                +Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
                +
                +```
                +my_component/src/ns.rb            -> MyComponent
                +my_component/src/widget.rb        -> MyComponent::Widget
                +my_component/assets/widget.js
                +my_component/tests/test_widget.rb
                +```
                +
                +Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
                +
                +However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
                +
                +Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
                +
                +Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
                +
                +```ruby
                +loader.nsfile = 'index.rb'
                +```
                +
                +there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
                @@ -375 +425 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -377 +427 @@
                -loader.tag = File.basename(__FILE__, ".rb")
                +loader.tag = File.basename(__FILE__, '.rb')
                @@ -387 +437 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -433 +483 @@
                -gem "net-http-niche_feature"
                +gem 'net-http-niche_feature'
                @@ -446 +496 @@
                -require "net/http/niche_feature"
                +require 'net/http/niche_feature'
                @@ -451,2 +501,2 @@
                -require "net/http"
                -require "zeitwerk"
                +require 'net/http'
                +require 'zeitwerk'
                @@ -467 +517 @@
                -  VERSION = "1.0.0"
                +  VERSION = '1.0.0'
                @@ -489 +539 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -498 +548 @@
                -That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                +That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                @@ -685 +735 @@
                -require "concurrent/atomic/read_write_lock"
                +require 'concurrent/atomic/read_write_lock'
                @@ -730,2 +780,2 @@
                -  "html_parser"   => "HTMLParser",
                -  "mysql_adapter" => "MySQLAdapter"
                +  'html_parser'   => 'HTMLParser',
                +  'mysql_adapter' => 'MySQLAdapter'
                @@ -738,2 +788,2 @@
                -loader.inflector.inflect "html_parser" => "HTMLParser"
                -loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
                +loader.inflector.inflect 'html_parser' => 'HTMLParser'
                +loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
                @@ -745 +795 @@
                -loader.inflector.inflect("xml" => "XML")
                +loader.inflector.inflect('xml' => 'XML')
                @@ -760,2 +810,2 @@
                -  "xml"        => "XML",
                -  "xml_parser" => "XMLParser"
                +  'xml'        => 'XML',
                +  'xml_parser' => 'XMLParser'
                @@ -816 +866 @@
                -      "HTML" + super($1, abspath)
                +      'HTML' + super($1, abspath)
                @@ -847,2 +897,2 @@
                -require "zeitwerk"
                -require_relative "my_gem/inflector"
                +require 'zeitwerk'
                +require_relative 'my_gem/inflector'
                @@ -916,2 +966,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.dev"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.dev'
                @@ -921,2 +971,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.prod"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.prod'
                @@ -966 +1016 @@
                -loader.on_unload("Country") do |klass, _abspath|
                +loader.on_unload('Country') do |klass, _abspath|
                @@ -1051 +1101 @@
                -loader.tag = "grep_me"
                +loader.tag = 'grep_me'
                @@ -1107 +1157 @@
                -require_relative "my_gem/core_ext/kernel"
                +require_relative 'my_gem/core_ext/kernel'
                @@ -1121 +1171 @@
                -require "pg"
                +require 'pg'
                @@ -1159 +1209 @@
                -require "foo"
                +require 'foo'
                @@ -1228,2 +1278,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -1231 +1281 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -1250,2 +1300,2 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.dirs # => ["/foo"]
                +loader.push_dir(Pathname.new('/foo'))
                +loader.dirs # => ['/foo']
                @@ -1258,3 +1308,3 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.push_dir(Pathname.new("/bar"), namespace: Bar)
                -loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
                +loader.push_dir(Pathname.new('/foo'))
                +loader.push_dir(Pathname.new('/bar'), namespace: Bar)
                +loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
                @@ -1287,4 +1337,4 @@
                -loader.cpath_expected_at("app/models")                  # => "Object"
                -loader.cpath_expected_at("app/models/user.rb")          # => "User"
                -loader.cpath_expected_at("app/models/hotel")            # => "Hotel"
                -loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
                +loader.cpath_expected_at('app/models')                  # => 'Object'
                +loader.cpath_expected_at('app/models/user.rb')          # => 'User'
                +loader.cpath_expected_at('app/models/hotel')            # => 'Hotel'
                +loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
                @@ -1296,3 +1346,3 @@
                -loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
                -loader.cpath_expected_at("a/b/collapsed")   # => "A::B", edge case
                -loader.cpath_expected_at("a/b")             # => "A::B"
                +loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
                +loader.cpath_expected_at('a/b/collapsed')   # => 'A::B', edge case
                +loader.cpath_expected_at('a/b')             # => 'A::B'
                @@ -1306 +1356 @@
                -loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
                +loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
                @@ -1312 +1362 @@
                -loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
                +loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
                @@ -1315 +1365,3 @@
                -This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1332 +1383,0 @@
                -lib/my_gem/ignored.rb
                @@ -1335,0 +1387 @@
                +lib/my_gem/ignored.rb
                @@ -1343,9 +1395,9 @@
                -  "/.../lib"                           => "Object",
                -  "/.../lib/my_gem.rb"                 => "MyGem",
                -  "/.../lib/my_gem"                    => "MyGem",
                -  "/.../lib/my_gem/version.rb"         => "MyGem::VERSION",
                -  "/.../lib/my_gem/drivers"            => "MyGem::Drivers",
                -  "/.../lib/my_gem/drivers/unix.rb"    => "MyGem::Drivers::Unix",
                -  "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
                -  "/.../lib/my_gem/collapsed"          => "MyGem",
                -  "/.../lib/my_gem/collapsed/foo.rb"   => "MyGem::Foo"
                +  '/.../lib'                           => 'Object',
                +  '/.../lib/my_gem.rb'                 => 'MyGem',
                +  '/.../lib/my_gem'                    => 'MyGem',
                +  '/.../lib/my_gem/version.rb'         => 'MyGem::VERSION',
                +  '/.../lib/my_gem/drivers'            => 'MyGem::Drivers',
                +  '/.../lib/my_gem/drivers/unix.rb'    => 'MyGem::Drivers::Unix',
                +  '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
                +  '/.../lib/my_gem/collapsed'          => 'MyGem',
                +  '/.../lib/my_gem/collapsed/foo.rb'   => 'MyGem::Foo'
                @@ -1357 +1409 @@
                -The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
                +The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
                @@ -1363 +1415,3 @@
                -This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1434,2 +1488,2 @@
                -test "capitalizes the first letter" do
                -  assert_equal "User", camelize("user")
                +test 'capitalizes the first letter' do
                +  assert_equal 'User', camelize('user')
                @@ -1449 +1503 @@
                -Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
                +Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
        lib/zeitwerk.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk.rb	2026-05-20 07:03:32.837698897 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk.rb	2026-05-20 07:03:32.841698869 +0000
                @@ -4,11 +4,11 @@
                -  require_relative "zeitwerk/real_mod_name"
                -  require_relative "zeitwerk/internal"
                -  require_relative "zeitwerk/cref"
                -  require_relative "zeitwerk/loader"
                -  require_relative "zeitwerk/gem_loader"
                -  require_relative "zeitwerk/registry"
                -  require_relative "zeitwerk/inflector"
                -  require_relative "zeitwerk/gem_inflector"
                -  require_relative "zeitwerk/null_inflector"
                -  require_relative "zeitwerk/error"
                -  require_relative "zeitwerk/version"
                +  require_relative 'zeitwerk/real_mod_name'
                +  require_relative 'zeitwerk/internal'
                +  require_relative 'zeitwerk/cref'
                +  require_relative 'zeitwerk/loader'
                +  require_relative 'zeitwerk/gem_loader'
                +  require_relative 'zeitwerk/registry'
                +  require_relative 'zeitwerk/inflector'
                +  require_relative 'zeitwerk/gem_inflector'
                +  require_relative 'zeitwerk/null_inflector'
                +  require_relative 'zeitwerk/error'
                +  require_relative 'zeitwerk/version'
                @@ -16,2 +16,2 @@
                -  require_relative "zeitwerk/core_ext/kernel"
                -  require_relative "zeitwerk/core_ext/module"
                +  require_relative 'zeitwerk/core_ext/kernel'
                +  require_relative 'zeitwerk/core_ext/module'
        lib/zeitwerk/core_ext/kernel.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:32.837698897 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:32.841698869 +0000
                @@ -25 +25 @@
                -      if path.end_with?(".rb")
                +      if path.end_with?('.rb')
        lib/zeitwerk/cref.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/cref.rb	2026-05-20 07:03:32.837698897 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/cref.rb	2026-05-20 07:03:32.841698869 +0000
                @@ -14 +14 @@
                -  require_relative "cref/map"
                +  require_relative 'cref/map'
                @@ -67,0 +68,7 @@
                +  end
                +
                +  #: () -> String?
                +  def location
                +    if (location = @mod.const_source_location(@cname)) && !location.empty?
                +      location.join(':')
                +    end
        lib/zeitwerk/cref/map.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:32.837698897 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:32.841698869 +0000
                @@ -30 +30 @@
                -#      { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
                +#      { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
        lib/zeitwerk/error.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/error.rb	2026-05-20 07:03:32.838698890 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/error.rb	2026-05-20 07:03:32.841698869 +0000
                @@ -20 +20,12 @@
                -      super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
                +      super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
                +    end
                +  end
                +
                +  class ConflictingNamespaceDefinitionError < Error
                +    #: (String, location: String?, conflicting_file: String) -> void
                +    def initialize(cpath, location:, conflicting_file:)
                +      if location
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
                +      else
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
                +      end
        lib/zeitwerk/gem_inflector.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:32.838698890 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:32.842698862 +0000
                @@ -7 +7 @@
                -      namespace     = File.basename(root_file, ".rb")
                +      namespace     = File.basename(root_file, '.rb')
                @@ -9 +9 @@
                -      @version_file = File.join(root_dir, namespace, "version.rb")
                +      @version_file = File.join(root_dir, namespace, 'version.rb')
                @@ -14 +14 @@
                -      abspath == @version_file ? "VERSION" : super
                +      abspath == @version_file ? 'VERSION' : super
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:32.838698890 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:32.842698862 +0000
                @@ -22,2 +22,2 @@
                -      @tag = File.basename(root_file, ".rb")
                -      @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
                +      @tag = File.basename(root_file, '.rb')
                +      @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
                @@ -43 +43 @@
                -      expected_namespace_dir = @root_file.delete_suffix(".rb")
                +      expected_namespace_dir = @root_file.delete_suffix('.rb')
                @@ -49 +49 @@
                -        basename_without_ext = basename.delete_suffix(".rb")
                +        basename_without_ext = basename.delete_suffix('.rb')
        lib/zeitwerk/inflector.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb	2026-05-20 07:03:32.838698890 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb	2026-05-20 07:03:32.842698862 +0000
                @@ -8,3 +8,3 @@
                -    #   inflector.camelize("post", ...)             # => "Post"
                -    #   inflector.camelize("users_controller", ...) # => "UsersController"
                -    #   inflector.camelize("api", ...)              # => "Api"
                +    #   inflector.camelize('post', ...)             # => 'Post'
                +    #   inflector.camelize('users_controller', ...) # => 'UsersController'
                +    #   inflector.camelize('api', ...)              # => 'Api'
                @@ -23,2 +23,2 @@
                -    #     "html_parser"   => "HTMLParser",
                -    #     "mysql_adapter" => "MySQLAdapter"
                +    #     'html_parser'   => 'HTMLParser',
                +    #     'mysql_adapter' => 'MySQLAdapter'
                @@ -27,3 +27,3 @@
                -    #   inflector.camelize("html_parser", abspath)      # => "HTMLParser"
                -    #   inflector.camelize("mysql_adapter", abspath)    # => "MySQLAdapter"
                -    #   inflector.camelize("users_controller", abspath) # => "UsersController"
                +    #   inflector.camelize('html_parser', abspath)      # => 'HTMLParser'
                +    #   inflector.camelize('mysql_adapter', abspath)    # => 'MySQLAdapter'
                +    #   inflector.camelize('users_controller', abspath) # => 'UsersController'
        lib/zeitwerk/loader.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-05-20 07:03:32.838698890 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader.rb	2026-05-20 07:03:32.842698862 +0000
                @@ -3,2 +3,2 @@
                -require "monitor"
                -require "set"
                +require 'monitor'
                +require 'set'
                @@ -8,5 +8,5 @@
                -    require_relative "loader/helpers"
                -    require_relative "loader/callbacks"
                -    require_relative "loader/config"
                -    require_relative "loader/eager_load"
                -    require_relative "loader/file_system"
                +    require_relative 'loader/helpers'
                +    require_relative 'loader/callbacks'
                +    require_relative 'loader/config'
                +    require_relative 'loader/eager_load'
                +    require_relative 'loader/file_system'
                @@ -25,2 +25,2 @@
                -    #   "/Users/fxn/blog/app/models/user.rb"          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                -    #   "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                +    #   '/Users/fxn/blog/app/models/user.rb'          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                +    #   '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                @@ -104,0 +105 @@
                +    #: () -> void
                @@ -132 +133 @@
                -          define_autoloads_for_dir(root_dir, root_namespace)
                +          define_autoloads_for_dir(root_dir, root_namespace, external: true)
                @@ -155,0 +157,3 @@
                +        __unload
                +      end
                +    end
                @@ -157,33 +161,19 @@
                -        # We are going to keep track of the files that were required by our
                -        # autoloads to later remove them from $LOADED_FEATURES, thus making them
                -        # loadable by Kernel#require again.
                -        #
                -        # Directories are not stored in $LOADED_FEATURES, keeping track of files
                -        # is enough.
                -        unloaded_files = Set.new
                -
                -        autoloads.each do |abspath, cref|
                -          if cref.autoload?
                -            unload_autoload(cref)
                -          else
                -            # Could happen if loaded with require_relative. That is unsupported,
                -            # and the constant path would escape unloadable_cpath? This is just
                -            # defensive code to clean things up as much as we are able to.
                -            unload_cref(cref)
                -            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                -          end
                -        end
                -
                -        to_unload.each do |abspath, cref|
                -          unless on_unload_callbacks.empty?
                -            begin
                -              value = cref.get
                -            rescue ::NameError
                -              # Perhaps the user deleted the constant by hand, or perhaps an
                -              # autoload failed to define the expected constant but the user
                -              # rescued the exception.
                -            else
                -              run_on_unload_callbacks(cref, value, abspath)
                -            end
                -          end
                -
                +    # This is an internal method.
                +    #
                +    #: () -> void
                +    def __unload
                +      # We are going to keep track of the files that were required by our
                +      # autoloads to later remove them from $LOADED_FEATURES, thus making them
                +      # loadable by Kernel#require again.
                +      #
                +      # Directories are not stored in $LOADED_FEATURES, keeping track of files
                +      # is enough.
                +      unloaded_files = Set.new
                +
                +      autoloads.each do |abspath, cref|
                +        if cref.autoload?
                +          unload_autoload(cref)
                +        else
                +          # Could happen if loaded with require_relative. That is unsupported,
                +          # and the constant path would escape unloadable_cpath? This is just
                +          # defensive code to clean things up as much as we are able to.
                @@ -192,0 +183 @@
                +      end
                @@ -194,13 +185,11 @@
                -        unless unloaded_files.empty?
                -          # Bootsnap decorates Kernel#require to speed it up using a cache and
                -          # this optimization does not check if $LOADED_FEATURES has the file.
                -          #
                -          # To make it aware of changes, the gem defines singleton methods in
                -          # $LOADED_FEATURES:
                -          #
                -          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                -          #
                -          # Rails applications may depend on bootsnap, so for unloading to work
                -          # in that setting it is preferable that we restrict our API choice to
                -          # one of those methods.
                -          $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      to_unload.each do |abspath, cref|
                +        unless on_unload_callbacks.empty?
                +          begin
                +            value = cref.get
                +          rescue ::NameError
                +            # Perhaps the user deleted the constant by hand, or perhaps an
                +            # autoload failed to define the expected constant but the user
                +            # rescued the exception.
                +          else
                +            run_on_unload_callbacks(cref, value, abspath)
                +          end
                @@ -209,5 +198,3 @@
                -        autoloads.clear
                -        autoloaded_dirs.clear
                -        to_unload.clear
                -        namespace_dirs.clear
                -        shadowed_files.clear
                +        unload_cref(cref)
                +        unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                +      end
                @@ -215,2 +202,14 @@
                -        unregister_inceptions
                -        unregister_explicit_namespaces
                +      unless unloaded_files.empty?
                +        # Bootsnap decorates Kernel#require to speed it up using a cache and
                +        # this optimization does not check if $LOADED_FEATURES has the file.
                +        #
                +        # To make it aware of changes, the gem defines singleton methods in
                +        # $LOADED_FEATURES:
                +        #
                +        #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +        #
                +        # Rails applications may depend on bootsnap, so for unloading to work
                +        # in that setting it is preferable that we restrict our API choice to
                +        # one of those methods.
                +        $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      end
                @@ -218 +217,5 @@
                -        Registry.autoloads.unregister_loader(self)
                +      autoloads.clear
                +      autoloaded_dirs.clear
                +      to_unload.clear
                +      namespace_dirs.clear
                +      shadowed_files.clear
                @@ -220,3 +223,7 @@
                -        @setup        = false
                -        @eager_loaded = false
                -      end
                +      unregister_inceptions
                +      unregister_explicit_namespaces
                +
                +      Registry.autoloads.unregister_loader(self)
                +
                +      @setup        = false
                +      @eager_loaded = false
                @@ -236,0 +244 @@
                +
                @@ -238,0 +247,2 @@
                +      recompute_collapse_parents
                +
                @@ -255 +265 @@
                -          prefix = cpath == "Object" ? "" : cpath + "::"
                +          prefix = cpath == 'Object' ? '' : cpath + '::'
                @@ -257 +267 @@
                -          @fs.ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
                @@ -259,5 +269,2 @@
                -              basename.delete_suffix!(".rb")
                -              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                -            else
                -              if collapse?(abspath)
                -                queue << [abspath, cpath]
                +              if basename == @nsfile
                +                result[abspath] = cpath
                @@ -265 +272,2 @@
                -                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                +                basename.delete_suffix!('.rb')
                +                result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266,0 +275,4 @@
                +            elsif collapse?(abspath)
                +              queue.unshift([abspath, cpath])
                +            else
                +              queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
                @@ -288,2 +300,2 @@
                -      if :file == ftype
                -        basename = File.basename(abspath, ".rb")
                +      if ftype == :file
                +        basename = File.basename(abspath)
                @@ -292 +304 @@
                -        paths << [basename, abspath]
                +        paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
                @@ -318 +330 @@
                -          cnames.join("::")
                +          cnames.join('::')
                @@ -320 +332 @@
                -          "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
                +          "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
                @@ -387 +399 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -390 +402 @@
                -      #   loader.tag = File.basename(__FILE__, ".rb")
                +      #   loader.tag = File.basename(__FILE__, '.rb')
                @@ -408 +420 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -411 +423 @@
                -      #   loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
                +      #   loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
                @@ -428 +440 @@
                -          raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
                +          raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
                @@ -476,2 +488,8 @@
                -    #: (String, Module) -> void
                -    private def define_autoloads_for_dir(dir, parent)
                +    # Scans `dir` and sets autoloads in `mod` for the constants its contents are
                +    # expected to define.
                +    #
                +    # The `external` flag indicates whether `mod` has been externally defined,
                +    # as is the case with root namespaces or reopened third-party namespaces.
                +    #
                +    #: (String, Module, external: boolish) -> void
                +    private def define_autoloads_for_dir(dir, mod, external:)
                @@ -480,9 +498,8 @@
                -          basename.delete_suffix!(".rb")
                -          cref = Cref.new(parent, cname_for(basename, abspath))
                -          autoload_file(cref, abspath)
                -        else
                -          if collapse?(abspath)
                -            define_autoloads_for_dir(abspath, parent)
                -          else
                -            cref = Cref.new(parent, cname_for(basename, abspath))
                -            autoload_subdir(cref, abspath)
                +          if basename == @nsfile
                +            if external
                +              cpath = real_mod_name(mod)
                +              location = Object.const_source_location(cpath)&.join(':')
                +              location = nil if location&.empty?
                +              raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
                +            end
                +            next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
                @@ -489,0 +507,7 @@
                +
                +          basename.delete_suffix!('.rb')
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_file(cref, abspath)
                +        else
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_subdir(cref, abspath, external:)
                @@ -495 +519,25 @@
                -    private def autoload_subdir(cref, subdir)
                +    private def visit_file(cref, file)
                +      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                +        if @fs.rb_extension?(autoload_path)
                +          if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
                +          end
                +          shadowed_files << file
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                +        else
                +          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                +        end
                +      elsif cref.defined?
                +        shadowed_files << file
                +        if location = cref.location
                +          log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
                +        else
                +          log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
                +        end
                +      else
                +        define_autoload(cref, file)
                +      end
                +    end
                +
                +    #: (Zeitwerk::Cref, String, external: boolish) -> void
                +    private def visit_subdir(cref, subdir, external:)
                @@ -497,0 +546,6 @@
                +          # The namespace that corresponds to this subdirectory is defined in a
                +          # file, either regular or nsfile. Therefore, a nsfile would be a
                +          # duplication.
                +          if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
                +          end
                @@ -499,2 +553 @@
                -          # constant has been found. This means we are dealing with an explicit
                -          # namespace whose definition was seen first.
                +          # constant has been found. This is an explicit namespace.
                @@ -502,3 +555,2 @@
                -          # Registering is idempotent, and we have to keep the autoload pointing
                -          # to the file. This may run again if more directories are found later
                -          # on, no big deal.
                +          # The namespace may be spread over multiple directories and perhaps it
                +          # was already registered, but registering is idempotent, just do it.
                @@ -505,0 +558,3 @@
                +        elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          # Scanning found a matching directory first, and now we saw a nsfile.
                +          promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
                @@ -507,3 +561,0 @@
                -        # If the existing autoload points to a file, it has to be preserved, if
                -        # not, it is fine as it is. In either case, we do not need to override.
                -        # Just remember the subdirectory conforms this namespace.
                @@ -512 +564,6 @@
                -        # First time we find this namespace, set an autoload for it.
                +        if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          define_autoload(cref, nsfile_abspath)
                +          register_explicit_namespace(cref)
                +        else
                +          define_autoload(cref, subdir)
                +        end
                @@ -514 +570,0 @@
                -        define_autoload(cref, subdir)
                @@ -519,19 +575 @@
                -        define_autoloads_for_dir(subdir, cref.get)
                -      end
                -    end
                -
                -    #: (Zeitwerk::Cref, String) -> void
                -    private def autoload_file(cref, file)
                -      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                -        # First autoload for a Ruby file wins, just ignore subsequent ones.
                -        if @fs.rb_extension?(autoload_path)
                -          shadowed_files << file
                -          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                -        else
                -          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                -        end
                -      elsif cref.defined?
                -        shadowed_files << file
                -        log { "file #{file} is ignored because #{cref} is already defined" }
                -      else
                -        define_autoload(cref, file)
                +        define_autoloads_for_dir(subdir, cref.get, external:)
                @@ -611 +649 @@
                -        require "pp" # Needed to have pretty_inspect available.
                +        require 'pp' # Needed to have pretty_inspect available.
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:32.838698890 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:32.842698862 +0000
                @@ -80 +80 @@
                -        define_autoloads_for_dir(dir, namespace)
                +        define_autoloads_for_dir(dir, namespace, external: false)
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:32.839698883 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:32.842698862 +0000
                @@ -3,2 +3,2 @@
                -require "set"
                -require "securerandom"
                +require 'set'
                +require 'securerandom'
                @@ -18,2 +18,2 @@
                -  #   "/Users/fxn/blog/app/channels" => Object,
                -  #   "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
                +  #   '/Users/fxn/blog/app/channels' => Object,
                +  #   '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
                @@ -31,0 +32,6 @@
                +  # Basename of files that define namespaces. For example, if `nsfile` is
                +  # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
                +  #
                +  #: String?
                +  attr_reader :nsfile
                +
                @@ -53 +59 @@
                -  # glob patterns were expanded. Computed on setup, and recomputed on reload.
                +  # glob patterns were expanded. Computed on setup and recomputed on reload.
                @@ -58,0 +65,8 @@
                +  # Absolute paths of directories that are parents of collapsed directories.
                +  # This is a cache to optimize some tree walks. Computed on setup and
                +  # recomputed on reload.
                +  #
                +  #: Set[String]
                +  attr_reader :collapse_parents
                +  private :collapse_parents
                +
                @@ -84,0 +99 @@
                +  #: () -> void
                @@ -90,0 +106 @@
                +    @nsfile                 = nil
                @@ -94,0 +111 @@
                +    @collapse_parents       = Set.new
                @@ -115 +132 @@
                -      raise Zeitwerk::Error, "root namespaces cannot be anonymous"
                +      raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
                @@ -143,0 +161,12 @@
                +  #: (String?) -> void ! TypeError, ArgumentError
                +  def nsfile=(nsfile)
                +    unless nsfile.nil?
                +      raise TypeError,     'nsfiles must be strings'              unless nsfile.is_a?(String)
                +      raise ArgumentError, 'nsfiles must have .rb extension'      unless @fs.rb_extension?(nsfile)
                +      raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
                +      raise ArgumentError, 'nsfiles cannot be hidden'             if @fs.hidden?(nsfile)
                +    end
                +
                +    @nsfile = nsfile
                +  end
                +
                @@ -179 +208 @@
                -        raise Zeitwerk::Error, "cannot enable reloading after setup"
                +        raise Zeitwerk::Error, 'cannot enable reloading after setup'
                @@ -217 +246,5 @@
                -      collapse_dirs.merge(expand_glob_patterns(glob_patterns))
                +      new_collapse_dirs = expand_glob_patterns(glob_patterns)
                +      collapse_dirs.merge(new_collapse_dirs)
                +      new_collapse_dirs.each do |dir|
                +        collapse_parents << File.dirname(dir)
                +      end
                @@ -236,2 +269,2 @@
                -  #   loader.on_load("SomeApiClient") do |klass, _abspath|
                -  #     klass.endpoint = "https://api.dev"
                +  #   loader.on_load('SomeApiClient') do |klass, _abspath|
                +  #     klass.endpoint = 'https://api.dev'
                @@ -248 +281 @@
                -    raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -259 +292 @@
                -  #   loader.on_unload("Country") do |klass, _abspath|
                +  #   loader.on_unload('Country') do |klass, _abspath|
                @@ -271 +304 @@
                -    raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -318,0 +352,10 @@
                +  internal def collapse?(dir)
                +    collapse_dirs.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                +  internal def collapse_parent?(dir)
                +    collapse_parents.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                @@ -331,5 +373,0 @@
                -  #: (String) -> bool
                -  private def collapse?(dir)
                -    collapse_dirs.member?(dir)
                -  end
                -
                @@ -355,0 +394,8 @@
                +  end
                +
                +  #: () -> void
                +  private def recompute_collapse_parents
                +    collapse_parents.clear
                +    collapse_dirs.each do |dir|
                +      collapse_parents << File.dirname(dir)
                +    end
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:32.839698883 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:32.842698862 +0000
                @@ -3,4 +3,5 @@
                -  # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
                -  # shadowed files are not eager loaded. You can opt-out specifically in
                -  # specific files and directories with `do_not_eager_load`, and that can be
                -  # overridden passing `force: true`.
                +  # need to be in `$LOAD_PATH`, absolute file names are used.
                +  #
                +  # Ignored files are not eager loaded. You can opt-out specifically in specific
                +  # files and directories with `do_not_eager_load`, and that can be overridden
                +  # passing `force: true`.
                @@ -14 +15 @@
                -      log { "eager load start" }
                +      log { 'eager load start' }
                @@ -27 +28 @@
                -      log { "eager load end" }
                +      log { 'eager load end' }
                @@ -94 +95 @@
                -        if root_namespace_name.start_with?(mod_name + "::")
                +        if root_namespace_name.start_with?(mod_name + '::')
                @@ -98 +99 @@
                -        elsif mod_name.start_with?(root_namespace_name + "::")
                +        elsif mod_name.start_with?(root_namespace_name + '::')
                @@ -122 +123 @@
                -    file_basename = File.basename(abspath, ".rb")
                +    file_basename = File.basename(abspath)
                @@ -141,2 +141,0 @@
                -    base_cname = cname_for(file_basename, abspath)
                -
                @@ -149,3 +148,8 @@
                -    raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
                -
                -    namespace.const_get(base_cname, false)
                +    if file_basename == @nsfile
                +      namespace
                +    elsif shadowed_file?(abspath)
                +      raise Zeitwerk::Error.new("#{abspath} is shadowed")
                +    else
                +      cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
                +      namespace.const_get(cname, false)
                +    end
                @@ -174,6 +178,2 @@
                -          if collapse?(abspath)
                -            queue << [abspath, namespace]
                -          else
                -            cname = cname_for(basename, abspath)
                -            queue << [abspath, namespace.const_get(cname, false)]
                -          end
                +          cname = cname_for(basename, abspath)
                +          queue << [abspath, namespace.const_get(cname, false)]
                @@ -194 +194 @@
                -      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
                +      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
                @@ -207 +207 @@
                -    suffix.split("::").each do |segment|
                +    suffix.split('::').each do |segment|
                @@ -210,5 +210 @@
                -          next unless ftype == :directory
                -
                -          if collapse?(abspath)
                -            dirs << abspath
                -          elsif segment == cname_for(basename, abspath).to_s
                +          if ftype == :directory && segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:32.839698883 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:32.842698862 +0000
                @@ -14,0 +15,14 @@
                +  # This method lists directories, filtering out the following:
                +  #
                +  # - Hidden entries.
                +  # - Ignored entries.
                +  # - Files whose extension is not `.rb`.
                +  # - Nested root directories, since they represent separate trees.
                +  # - Subdirectories that (recursively) contain no Ruby files.
                +  #
                +  # If `collapse` is true, collapsed directories are not yielded, instead, the
                +  # method recurses so that the caller gets a conceptually flat listing.
                +  #
                +  # For every entry that is not excluded, `ls` yields its basename, absolute
                +  # path, and file type, which can only be :file or :directory.
                +  #
                @@ -16 +30 @@
                -  def ls(dir)
                +  def ls(dir, collapse: true, &block)
                @@ -27,3 +41,8 @@
                -      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                -        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                -        next
                +      if ftype == :directory
                +        if !has_at_least_one_ruby_file?(abspath)
                +          @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +          next
                +        elsif collapse && @loader.__collapse?(abspath)
                +          ls(abspath, collapse: collapse, &block)
                +          next
                +        end
                @@ -41 +60,38 @@
                -      break if basename == "/"
                +      break if basename == '/'
                +    end
                +  end
                +
                +  # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
                +  # there is none, it returns `nil`.
                +  #
                +  # This method accounts for collapsed directories, which conceptually allow for
                +  # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
                +  #
                +  #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
                +  def has_exactly_one_nsfile?(cref, dir)
                +    return unless @loader.nsfile
                +
                +    # When `dir` does not have any collapsed directories a simple lookup
                +    # suffices. This is a common case worth optimizing.
                +    unless @loader.__collapse_parent?(dir)
                +      nsfile_abspath = File.join(dir, @loader.nsfile)
                +      if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
                +        return nsfile_abspath
                +      end
                +      return
                +    end
                +
                +    nsfile = nil
                +
                +    to_visit = [dir]
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |basename, abspath, ftype|
                +        if ftype == :file && basename == @loader.nsfile
                +          if nsfile
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
                +          end
                +          nsfile = abspath
                +        elsif ftype == :directory && @loader.__collapse?(abspath)
                +          to_visit << abspath
                +        end
                +      end
                @@ -42,0 +99,2 @@
                +
                +    nsfile
                @@ -58 +116 @@
                -    path.end_with?(".rb")
                +    path.end_with?('.rb')
                @@ -68 +126 @@
                -    basename.start_with?(".")
                +    basename.start_with?('.')
                @@ -83 +141 @@
                -        return true if :file == ftype
                +        return true if ftype == :file
                @@ -99,12 +157,3 @@
                -      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
                +      if ftype == :file
                +        yield basename, abspath, ftype
                +      else
                @@ -138 +187 @@
                -        elsif :directory == ftype
                +        elsif ftype == :directory
                @@ -141 +190 @@
                -        elsif :link == ftype
                +        elsif ftype == :link
                @@ -158,3 +207 @@
                -          if dir?(abspath)
                -            yield basename, abspath, :directory
                -          end
                +          yield basename, abspath, :directory if dir?(abspath)
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:32.839698883 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:32.843698855 +0000
                @@ -15 +15 @@
                -    if cname.include?("::")
                +    if cname.include?('::')
                @@ -28 +28 @@
                -      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
        lib/zeitwerk/registry.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-05-20 07:03:32.839698883 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/registry.rb	2026-05-20 07:03:32.843698855 +0000
                @@ -5,4 +5,4 @@
                -    require_relative "registry/autoloads"
                -    require_relative "registry/explicit_namespaces"
                -    require_relative "registry/inceptions"
                -    require_relative "registry/loaders"
                +    require_relative 'registry/autoloads'
                +    require_relative 'registry/explicit_namespaces'
                +    require_relative 'registry/inceptions'
                +    require_relative 'registry/loaders'
                @@ -57,2 +57,2 @@
                -              new_root_dir_slash = new_root_dir + "/"
                -              existing_root_dir_slash = existing_root_dir + "/"
                +              new_root_dir_slash = new_root_dir + '/'
                +              existing_root_dir_slash = existing_root_dir + '/'
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:32.839698883 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:32.843698855 +0000
                @@ -9,2 +9,2 @@
                -    def each(&)
                -      @loaders.each(&)
                +    def each(&block)
                +      @loaders.each(&block)
        lib/zeitwerk/version.rb
                --- /tmp/d20260520-429-clg2n8/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-05-20 07:03:32.839698883 +0000
                +++ /tmp/d20260520-429-clg2n8/zeitwerk-2.8.1/lib/zeitwerk/version.rb	2026-05-20 07:03:32.843698855 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.5"
                +  VERSION = '2.8.1'

@github-actions

Copy link
Copy Markdown
Contributor

gem compare --diff zeitwerk 2.7.5 2.8.1

Compared versions: ["2.7.5", "2.8.1"]
  DIFFERENT files:
    2.7.5->2.8.1:
      * Changed:
        README.md
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/README.md	2026-05-20 07:03:34.929312910 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/README.md	2026-05-20 07:03:34.933312885 +0000
                @@ -21,0 +22,2 @@
                +    - [Explicit namespaces defined in ordinary files](#explicit-namespaces-defined-in-ordinary-files)
                +    - [Explicit namespaces defined in nsfiles](#explicit-namespaces-defined-in-nsfiles)
                @@ -101 +103 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -184 +186 @@
                -loader.inflector.inflect("max_retries" => "MAX_RETRIES")
                +loader.inflector.inflect('max_retries' => 'MAX_RETRIES')
                @@ -223,2 +225,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -267 +269,6 @@
                -Classes and modules that act as namespaces can also be explicitly defined, though. For instance, consider
                +Classes and modules that act as namespaces can also be explicitly defined in a file. This can be done with ordinary files named after the corresponding constant path, or with special namespace files, or _nsfiles_ for short.
                +
                +<a id="markdown-explicit-namespaces-defined-in-ordinary-files" name="explicit-namespaces-defined-in-ordinary-files"></a>
                +#### Explicit namespaces defined in ordinary files
                +
                +Let's consider:
                @@ -274 +281 @@
                -There, `app/models/hotel.rb` defines `Hotel`, and thus Zeitwerk does not autovivify a module.
                +Since there is a file `app/models/hotel.rb` and also a directory `app/models/hotel`, Zeitwerk realizes `Hotel` is a namespace that is defined in `app/models/hotel.rb`.
                @@ -276 +283,3 @@
                -The classes and modules from the namespace are already available in the body of the class or module defining it:
                +In order to realize this, the directory or directories conforming the namespace do not need to be next to the file, as in the example, they could be in some other root directory.
                +
                +The classes and modules from an explicit namespace are already available in the body of the class or module that defines it:
                @@ -287 +296,42 @@
                -An explicit namespace must be managed by one single loader. Loaders that reopen namespaces owned by other projects are responsible for loading their constants before setup.
                +<a id="markdown-explicit-namespaces-defined-in-nsfiles" name="explicit-namespaces-defined-in-nsfiles"></a>
                +#### Explicit namespaces defined in nsfiles
                +
                +If the loader has an nsfile configured (defaults to `nil`):
                +
                +```ruby
                +loader.nsfile = 'ns.rb' # must be set before setup
                +```
                +
                +you can alternatively define the explicit namespace inside its directory:
                +
                +```
                +my_component/ns.rb     -> MyComponent
                +my_component/widget.rb -> MyComponent::Widget
                +```
                +
                +This may be handy for self-contained units for which a `my_component.rb` file in the parent directory would feel unnatural.
                +
                +A loader's nsfile has to be a non-hidden basename with a `.rb` extension, as in the example above. Nsfiles are not inflected, so as long as those conditions hold, they may contain leading underscores, hyphens, etc.
                +
                +Collapsed directories work as expected. For example, if we assume that `src` is collapsed, and that `assets` and `tests` are ignored, you could have the code organized this way:
                +
                +```
                +my_component/src/ns.rb            -> MyComponent
                +my_component/src/widget.rb        -> MyComponent::Widget
                +my_component/assets/widget.js
                +my_component/tests/test_widget.rb
                +```
                +
                +Loaders with an nsfile configured also support explicit namespaces defined in ordinary files. The conventions are not exclusive project-wide. Some parts may be component-oriented, while in other parts ordinary files may feel more natural. That works.
                +
                +However, attempting to define the same namespace using an ordinary file and an nsfile is an error condition that raises `Zeitwerk::ConflictingNamespaceDefinitionError`.
                +
                +Nsfiles in root directories raise `Zeitwerk::ConflictingNamespaceDefinitionError` too, since the namespace in a root directory is externally defined.
                +
                +Non-ignored files whose basename is equal to the nsfile are always considered to be nsfiles. You cannot opt out. Therefore, if we have:
                +
                +```ruby
                +loader.nsfile = 'index.rb'
                +```
                +
                +there is no way `foo/index.rb` can define `Foo::Index` in any part of the project, it must define `Foo`.
                @@ -375 +425 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -377 +427 @@
                -loader.tag = File.basename(__FILE__, ".rb")
                +loader.tag = File.basename(__FILE__, '.rb')
                @@ -387 +437 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -433 +483 @@
                -gem "net-http-niche_feature"
                +gem 'net-http-niche_feature'
                @@ -446 +496 @@
                -require "net/http/niche_feature"
                +require 'net/http/niche_feature'
                @@ -451,2 +501,2 @@
                -require "net/http"
                -require "zeitwerk"
                +require 'net/http'
                +require 'zeitwerk'
                @@ -467 +517 @@
                -  VERSION = "1.0.0"
                +  VERSION = '1.0.0'
                @@ -489 +539 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -498 +548 @@
                -That works, and there is no `require "my_gem/my_logger"`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                +That works, and there is no `require 'my_gem/my_logger'`. When `(*)` is reached, Zeitwerk seamlessly autoloads `MyGem::MyLogger`.
                @@ -685 +735 @@
                -require "concurrent/atomic/read_write_lock"
                +require 'concurrent/atomic/read_write_lock'
                @@ -730,2 +780,2 @@
                -  "html_parser"   => "HTMLParser",
                -  "mysql_adapter" => "MySQLAdapter"
                +  'html_parser'   => 'HTMLParser',
                +  'mysql_adapter' => 'MySQLAdapter'
                @@ -738,2 +788,2 @@
                -loader.inflector.inflect "html_parser" => "HTMLParser"
                -loader.inflector.inflect "mysql_adapter" => "MySQLAdapter"
                +loader.inflector.inflect 'html_parser' => 'HTMLParser'
                +loader.inflector.inflect 'mysql_adapter' => 'MySQLAdapter'
                @@ -745 +795 @@
                -loader.inflector.inflect("xml" => "XML")
                +loader.inflector.inflect('xml' => 'XML')
                @@ -760,2 +810,2 @@
                -  "xml"        => "XML",
                -  "xml_parser" => "XMLParser"
                +  'xml'        => 'XML',
                +  'xml_parser' => 'XMLParser'
                @@ -816 +866 @@
                -      "HTML" + super($1, abspath)
                +      'HTML' + super($1, abspath)
                @@ -847,2 +897,2 @@
                -require "zeitwerk"
                -require_relative "my_gem/inflector"
                +require 'zeitwerk'
                +require_relative 'my_gem/inflector'
                @@ -916,2 +966,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.dev"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.dev'
                @@ -921,2 +971,2 @@
                -loader.on_load("SomeApiClient") do |klass, _abspath|
                -  klass.endpoint = "https://api.prod"
                +loader.on_load('SomeApiClient') do |klass, _abspath|
                +  klass.endpoint = 'https://api.prod'
                @@ -966 +1016 @@
                -loader.on_unload("Country") do |klass, _abspath|
                +loader.on_unload('Country') do |klass, _abspath|
                @@ -1051 +1101 @@
                -loader.tag = "grep_me"
                +loader.tag = 'grep_me'
                @@ -1107 +1157 @@
                -require_relative "my_gem/core_ext/kernel"
                +require_relative 'my_gem/core_ext/kernel'
                @@ -1121 +1171 @@
                -require "pg"
                +require 'pg'
                @@ -1159 +1209 @@
                -require "foo"
                +require 'foo'
                @@ -1228,2 +1278,2 @@
                -require "active_job"
                -require "active_job/queue_adapters"
                +require 'active_job'
                +require 'active_job/queue_adapters'
                @@ -1231 +1281 @@
                -require "zeitwerk"
                +require 'zeitwerk'
                @@ -1250,2 +1300,2 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.dirs # => ["/foo"]
                +loader.push_dir(Pathname.new('/foo'))
                +loader.dirs # => ['/foo']
                @@ -1258,3 +1308,3 @@
                -loader.push_dir(Pathname.new("/foo"))
                -loader.push_dir(Pathname.new("/bar"), namespace: Bar)
                -loader.dirs(namespaces: true) # => { "/foo" => Object, "/bar" => Bar }
                +loader.push_dir(Pathname.new('/foo'))
                +loader.push_dir(Pathname.new('/bar'), namespace: Bar)
                +loader.dirs(namespaces: true) # => { '/foo' => Object, '/bar' => Bar }
                @@ -1287,4 +1337,4 @@
                -loader.cpath_expected_at("app/models")                  # => "Object"
                -loader.cpath_expected_at("app/models/user.rb")          # => "User"
                -loader.cpath_expected_at("app/models/hotel")            # => "Hotel"
                -loader.cpath_expected_at("app/models/hotel/billing.rb") # => "Hotel::Billing"
                +loader.cpath_expected_at('app/models')                  # => 'Object'
                +loader.cpath_expected_at('app/models/user.rb')          # => 'User'
                +loader.cpath_expected_at('app/models/hotel')            # => 'Hotel'
                +loader.cpath_expected_at('app/models/hotel/billing.rb') # => 'Hotel::Billing'
                @@ -1296,3 +1346,3 @@
                -loader.cpath_expected_at("a/b/collapsed/c") # => "A::B::C"
                -loader.cpath_expected_at("a/b/collapsed")   # => "A::B", edge case
                -loader.cpath_expected_at("a/b")             # => "A::B"
                +loader.cpath_expected_at('a/b/collapsed/c') # => 'A::B::C'
                +loader.cpath_expected_at('a/b/collapsed')   # => 'A::B', edge case
                +loader.cpath_expected_at('a/b')             # => 'A::B'
                @@ -1306 +1356 @@
                -loader.cpath_expected_at("non_existing_file.rb") # => Zeitwerk::Error
                +loader.cpath_expected_at('non_existing_file.rb') # => Zeitwerk::Error
                @@ -1312 +1362 @@
                -loader.cpath_expected_at("8.rb") # => Zeitwerk::NameError
                +loader.cpath_expected_at('8.rb') # => Zeitwerk::NameError
                @@ -1315 +1365,3 @@
                -This method does not parse file contents and does not guarantee files define the returned constant path. It just says which is the _expected_ one.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1332 +1383,0 @@
                -lib/my_gem/ignored.rb
                @@ -1335,0 +1387 @@
                +lib/my_gem/ignored.rb
                @@ -1343,9 +1395,9 @@
                -  "/.../lib"                           => "Object",
                -  "/.../lib/my_gem.rb"                 => "MyGem",
                -  "/.../lib/my_gem"                    => "MyGem",
                -  "/.../lib/my_gem/version.rb"         => "MyGem::VERSION",
                -  "/.../lib/my_gem/drivers"            => "MyGem::Drivers",
                -  "/.../lib/my_gem/drivers/unix.rb"    => "MyGem::Drivers::Unix",
                -  "/.../lib/my_gem/drivers/windows.rb" => "MyGem::Drivers::Windows",
                -  "/.../lib/my_gem/collapsed"          => "MyGem",
                -  "/.../lib/my_gem/collapsed/foo.rb"   => "MyGem::Foo"
                +  '/.../lib'                           => 'Object',
                +  '/.../lib/my_gem.rb'                 => 'MyGem',
                +  '/.../lib/my_gem'                    => 'MyGem',
                +  '/.../lib/my_gem/version.rb'         => 'MyGem::VERSION',
                +  '/.../lib/my_gem/drivers'            => 'MyGem::Drivers',
                +  '/.../lib/my_gem/drivers/unix.rb'    => 'MyGem::Drivers::Unix',
                +  '/.../lib/my_gem/drivers/windows.rb' => 'MyGem::Drivers::Windows',
                +  '/.../lib/my_gem/collapsed'          => 'MyGem',
                +  '/.../lib/my_gem/collapsed/foo.rb'   => 'MyGem::Foo'
                @@ -1357 +1409 @@
                -The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also not present because it contains no files with extension ".rb".
                +The file `lib/.DS_Store` is hidden, hence excluded. The directory `lib/tasks` is also excluded because it contains no files with extension ".rb".
                @@ -1363 +1415,3 @@
                -This method does not parse or execute file contents and does not guarantee files define the corresponding constant paths. It just says which are the _expected_ ones.
                +This method does not parse file contents and does not guarantee files define the returned constant path.
                +
                +Similarly, this method does not validate the project tree. If the project has conflicting oridinary and nsfiles for the same namespace, for example, the call will return the expected constant path for each of them without raising.
                @@ -1434,2 +1488,2 @@
                -test "capitalizes the first letter" do
                -  assert_equal "User", camelize("user")
                +test 'capitalizes the first letter' do
                +  assert_equal 'User', camelize('user')
                @@ -1449 +1503 @@
                -Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require "foo/bar"` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
                +Also, if the project has namespaces, setting things up and getting client code to load things in a consistent way needs discipline. For example, `require 'foo/bar'` may define `Foo`, instead of reopen it. That may be a broken window, giving place to superclass mismatches or partially-defined namespaces.
        lib/zeitwerk.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk.rb	2026-05-20 07:03:34.929312910 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk.rb	2026-05-20 07:03:34.933312885 +0000
                @@ -4,11 +4,11 @@
                -  require_relative "zeitwerk/real_mod_name"
                -  require_relative "zeitwerk/internal"
                -  require_relative "zeitwerk/cref"
                -  require_relative "zeitwerk/loader"
                -  require_relative "zeitwerk/gem_loader"
                -  require_relative "zeitwerk/registry"
                -  require_relative "zeitwerk/inflector"
                -  require_relative "zeitwerk/gem_inflector"
                -  require_relative "zeitwerk/null_inflector"
                -  require_relative "zeitwerk/error"
                -  require_relative "zeitwerk/version"
                +  require_relative 'zeitwerk/real_mod_name'
                +  require_relative 'zeitwerk/internal'
                +  require_relative 'zeitwerk/cref'
                +  require_relative 'zeitwerk/loader'
                +  require_relative 'zeitwerk/gem_loader'
                +  require_relative 'zeitwerk/registry'
                +  require_relative 'zeitwerk/inflector'
                +  require_relative 'zeitwerk/gem_inflector'
                +  require_relative 'zeitwerk/null_inflector'
                +  require_relative 'zeitwerk/error'
                +  require_relative 'zeitwerk/version'
                @@ -16,2 +16,2 @@
                -  require_relative "zeitwerk/core_ext/kernel"
                -  require_relative "zeitwerk/core_ext/module"
                +  require_relative 'zeitwerk/core_ext/kernel'
                +  require_relative 'zeitwerk/core_ext/module'
        lib/zeitwerk/core_ext/kernel.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:34.929312910 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/core_ext/kernel.rb	2026-05-20 07:03:34.935312872 +0000
                @@ -25 +25 @@
                -      if path.end_with?(".rb")
                +      if path.end_with?('.rb')
        lib/zeitwerk/cref.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/cref.rb	2026-05-20 07:03:34.930312904 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/cref.rb	2026-05-20 07:03:34.935312872 +0000
                @@ -14 +14 @@
                -  require_relative "cref/map"
                +  require_relative 'cref/map'
                @@ -67,0 +68,7 @@
                +  end
                +
                +  #: () -> String?
                +  def location
                +    if (location = @mod.const_source_location(@cname)) && !location.empty?
                +      location.join(':')
                +    end
        lib/zeitwerk/cref/map.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:34.930312904 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/cref/map.rb	2026-05-20 07:03:34.935312872 +0000
                @@ -30 +30 @@
                -#      { "M::X" => 0, "M::Y" => 1, "N::Z" => 2 }
                +#      { 'M::X' => 0, 'M::Y' => 1, 'N::Z' => 2 }
        lib/zeitwerk/error.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/error.rb	2026-05-20 07:03:34.930312904 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/error.rb	2026-05-20 07:03:34.935312872 +0000
                @@ -20 +20,12 @@
                -      super("please, finish your configuration and call Zeitwerk::Loader#setup once all is ready")
                +      super('please, finish your configuration and call Zeitwerk::Loader#setup once all is ready')
                +    end
                +  end
                +
                +  class ConflictingNamespaceDefinitionError < Error
                +    #: (String, location: String?, conflicting_file: String) -> void
                +    def initialize(cpath, location:, conflicting_file:)
                +      if location
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with #{location}")
                +      else
                +        super("conflicting namespace definition for #{cpath}: #{conflicting_file} conflicts with an already defined namespace")
                +      end
        lib/zeitwerk/gem_inflector.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:34.930312904 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/gem_inflector.rb	2026-05-20 07:03:34.935312872 +0000
                @@ -7 +7 @@
                -      namespace     = File.basename(root_file, ".rb")
                +      namespace     = File.basename(root_file, '.rb')
                @@ -9 +9 @@
                -      @version_file = File.join(root_dir, namespace, "version.rb")
                +      @version_file = File.join(root_dir, namespace, 'version.rb')
                @@ -14 +14 @@
                -      abspath == @version_file ? "VERSION" : super
                +      abspath == @version_file ? 'VERSION' : super
        lib/zeitwerk/gem_loader.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:34.930312904 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/gem_loader.rb	2026-05-20 07:03:34.935312872 +0000
                @@ -22,2 +22,2 @@
                -      @tag = File.basename(root_file, ".rb")
                -      @tag = real_mod_name(namespace) + "-" + @tag unless namespace.equal?(Object)
                +      @tag = File.basename(root_file, '.rb')
                +      @tag = real_mod_name(namespace) + '-' + @tag unless namespace.equal?(Object)
                @@ -43 +43 @@
                -      expected_namespace_dir = @root_file.delete_suffix(".rb")
                +      expected_namespace_dir = @root_file.delete_suffix('.rb')
                @@ -49 +49 @@
                -        basename_without_ext = basename.delete_suffix(".rb")
                +        basename_without_ext = basename.delete_suffix('.rb')
        lib/zeitwerk/inflector.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/inflector.rb	2026-05-20 07:03:34.930312904 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/inflector.rb	2026-05-20 07:03:34.935312872 +0000
                @@ -8,3 +8,3 @@
                -    #   inflector.camelize("post", ...)             # => "Post"
                -    #   inflector.camelize("users_controller", ...) # => "UsersController"
                -    #   inflector.camelize("api", ...)              # => "Api"
                +    #   inflector.camelize('post', ...)             # => 'Post'
                +    #   inflector.camelize('users_controller', ...) # => 'UsersController'
                +    #   inflector.camelize('api', ...)              # => 'Api'
                @@ -23,2 +23,2 @@
                -    #     "html_parser"   => "HTMLParser",
                -    #     "mysql_adapter" => "MySQLAdapter"
                +    #     'html_parser'   => 'HTMLParser',
                +    #     'mysql_adapter' => 'MySQLAdapter'
                @@ -27,3 +27,3 @@
                -    #   inflector.camelize("html_parser", abspath)      # => "HTMLParser"
                -    #   inflector.camelize("mysql_adapter", abspath)    # => "MySQLAdapter"
                -    #   inflector.camelize("users_controller", abspath) # => "UsersController"
                +    #   inflector.camelize('html_parser', abspath)      # => 'HTMLParser'
                +    #   inflector.camelize('mysql_adapter', abspath)    # => 'MySQLAdapter'
                +    #   inflector.camelize('users_controller', abspath) # => 'UsersController'
        lib/zeitwerk/loader.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader.rb	2026-05-20 07:03:34.931312897 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader.rb	2026-05-20 07:03:34.936312865 +0000
                @@ -3,2 +3,2 @@
                -require "monitor"
                -require "set"
                +require 'monitor'
                +require 'set'
                @@ -8,5 +8,5 @@
                -    require_relative "loader/helpers"
                -    require_relative "loader/callbacks"
                -    require_relative "loader/config"
                -    require_relative "loader/eager_load"
                -    require_relative "loader/file_system"
                +    require_relative 'loader/helpers'
                +    require_relative 'loader/callbacks'
                +    require_relative 'loader/config'
                +    require_relative 'loader/eager_load'
                +    require_relative 'loader/file_system'
                @@ -25,2 +25,2 @@
                -    #   "/Users/fxn/blog/app/models/user.rb"          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                -    #   "/Users/fxn/blog/app/models/hotel/pricing.rb" => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                +    #   '/Users/fxn/blog/app/models/user.rb'          => #<Zeitwerk::Cref:... @mod=Object, @cname=:User, ...>,
                +    #   '/Users/fxn/blog/app/models/hotel/pricing.rb' => #<Zeitwerk::Cref:... @mod=Hotel, @cname=:Pricing, ...>,
                @@ -104,0 +105 @@
                +    #: () -> void
                @@ -132 +133 @@
                -          define_autoloads_for_dir(root_dir, root_namespace)
                +          define_autoloads_for_dir(root_dir, root_namespace, external: true)
                @@ -155,0 +157,3 @@
                +        __unload
                +      end
                +    end
                @@ -157,33 +161,19 @@
                -        # We are going to keep track of the files that were required by our
                -        # autoloads to later remove them from $LOADED_FEATURES, thus making them
                -        # loadable by Kernel#require again.
                -        #
                -        # Directories are not stored in $LOADED_FEATURES, keeping track of files
                -        # is enough.
                -        unloaded_files = Set.new
                -
                -        autoloads.each do |abspath, cref|
                -          if cref.autoload?
                -            unload_autoload(cref)
                -          else
                -            # Could happen if loaded with require_relative. That is unsupported,
                -            # and the constant path would escape unloadable_cpath? This is just
                -            # defensive code to clean things up as much as we are able to.
                -            unload_cref(cref)
                -            unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                -          end
                -        end
                -
                -        to_unload.each do |abspath, cref|
                -          unless on_unload_callbacks.empty?
                -            begin
                -              value = cref.get
                -            rescue ::NameError
                -              # Perhaps the user deleted the constant by hand, or perhaps an
                -              # autoload failed to define the expected constant but the user
                -              # rescued the exception.
                -            else
                -              run_on_unload_callbacks(cref, value, abspath)
                -            end
                -          end
                -
                +    # This is an internal method.
                +    #
                +    #: () -> void
                +    def __unload
                +      # We are going to keep track of the files that were required by our
                +      # autoloads to later remove them from $LOADED_FEATURES, thus making them
                +      # loadable by Kernel#require again.
                +      #
                +      # Directories are not stored in $LOADED_FEATURES, keeping track of files
                +      # is enough.
                +      unloaded_files = Set.new
                +
                +      autoloads.each do |abspath, cref|
                +        if cref.autoload?
                +          unload_autoload(cref)
                +        else
                +          # Could happen if loaded with require_relative. That is unsupported,
                +          # and the constant path would escape unloadable_cpath? This is just
                +          # defensive code to clean things up as much as we are able to.
                @@ -192,0 +183 @@
                +      end
                @@ -194,13 +185,11 @@
                -        unless unloaded_files.empty?
                -          # Bootsnap decorates Kernel#require to speed it up using a cache and
                -          # this optimization does not check if $LOADED_FEATURES has the file.
                -          #
                -          # To make it aware of changes, the gem defines singleton methods in
                -          # $LOADED_FEATURES:
                -          #
                -          #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                -          #
                -          # Rails applications may depend on bootsnap, so for unloading to work
                -          # in that setting it is preferable that we restrict our API choice to
                -          # one of those methods.
                -          $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      to_unload.each do |abspath, cref|
                +        unless on_unload_callbacks.empty?
                +          begin
                +            value = cref.get
                +          rescue ::NameError
                +            # Perhaps the user deleted the constant by hand, or perhaps an
                +            # autoload failed to define the expected constant but the user
                +            # rescued the exception.
                +          else
                +            run_on_unload_callbacks(cref, value, abspath)
                +          end
                @@ -209,5 +198,3 @@
                -        autoloads.clear
                -        autoloaded_dirs.clear
                -        to_unload.clear
                -        namespace_dirs.clear
                -        shadowed_files.clear
                +        unload_cref(cref)
                +        unloaded_files.add(abspath) if @fs.rb_extension?(abspath)
                +      end
                @@ -215,2 +202,14 @@
                -        unregister_inceptions
                -        unregister_explicit_namespaces
                +      unless unloaded_files.empty?
                +        # Bootsnap decorates Kernel#require to speed it up using a cache and
                +        # this optimization does not check if $LOADED_FEATURES has the file.
                +        #
                +        # To make it aware of changes, the gem defines singleton methods in
                +        # $LOADED_FEATURES:
                +        #
                +        #   https://github.com/rails/bootsnap/blob/main/lib/bootsnap/load_path_cache/core_ext/loaded_features.rb
                +        #
                +        # Rails applications may depend on bootsnap, so for unloading to work
                +        # in that setting it is preferable that we restrict our API choice to
                +        # one of those methods.
                +        $LOADED_FEATURES.reject! { |file| unloaded_files.member?(file) }
                +      end
                @@ -218 +217,5 @@
                -        Registry.autoloads.unregister_loader(self)
                +      autoloads.clear
                +      autoloaded_dirs.clear
                +      to_unload.clear
                +      namespace_dirs.clear
                +      shadowed_files.clear
                @@ -220,3 +223,7 @@
                -        @setup        = false
                -        @eager_loaded = false
                -      end
                +      unregister_inceptions
                +      unregister_explicit_namespaces
                +
                +      Registry.autoloads.unregister_loader(self)
                +
                +      @setup        = false
                +      @eager_loaded = false
                @@ -236,0 +244 @@
                +
                @@ -238,0 +247,2 @@
                +      recompute_collapse_parents
                +
                @@ -255 +265 @@
                -          prefix = cpath == "Object" ? "" : cpath + "::"
                +          prefix = cpath == 'Object' ? '' : cpath + '::'
                @@ -257 +267 @@
                -          @fs.ls(dir) do |basename, abspath, ftype|
                +          @fs.ls(dir, collapse: false) do |basename, abspath, ftype|
                @@ -259,5 +269,2 @@
                -              basename.delete_suffix!(".rb")
                -              result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                -            else
                -              if collapse?(abspath)
                -                queue << [abspath, cpath]
                +              if basename == @nsfile
                +                result[abspath] = cpath
                @@ -265 +272,2 @@
                -                queue << [abspath, "#{prefix}#{cname_for(basename, abspath)}"]
                +                basename.delete_suffix!('.rb')
                +                result[abspath] = "#{prefix}#{cname_for(basename, abspath)}"
                @@ -266,0 +275,4 @@
                +            elsif collapse?(abspath)
                +              queue.unshift([abspath, cpath])
                +            else
                +              queue.push([abspath, "#{prefix}#{cname_for(basename, abspath)}"])
                @@ -288,2 +300,2 @@
                -      if :file == ftype
                -        basename = File.basename(abspath, ".rb")
                +      if ftype == :file
                +        basename = File.basename(abspath)
                @@ -292 +304 @@
                -        paths << [basename, abspath]
                +        paths << [basename.delete_suffix('.rb'), abspath] unless basename == @nsfile
                @@ -318 +330 @@
                -          cnames.join("::")
                +          cnames.join('::')
                @@ -320 +332 @@
                -          "#{real_mod_name(root_namespace)}::#{cnames.join("::")}"
                +          "#{real_mod_name(root_namespace)}::#{cnames.join('::')}"
                @@ -387 +399 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -390 +402 @@
                -      #   loader.tag = File.basename(__FILE__, ".rb")
                +      #   loader.tag = File.basename(__FILE__, '.rb')
                @@ -408 +420 @@
                -      #   require "zeitwerk"
                +      #   require 'zeitwerk'
                @@ -411 +423 @@
                -      #   loader.tag = namespace.name + "-" + File.basename(__FILE__, ".rb")
                +      #   loader.tag = namespace.name + '-' + File.basename(__FILE__, '.rb')
                @@ -428 +440 @@
                -          raise Zeitwerk::Error, "extending anonymous namespaces is unsupported"
                +          raise Zeitwerk::Error, 'extending anonymous namespaces is unsupported'
                @@ -476,2 +488,8 @@
                -    #: (String, Module) -> void
                -    private def define_autoloads_for_dir(dir, parent)
                +    # Scans `dir` and sets autoloads in `mod` for the constants its contents are
                +    # expected to define.
                +    #
                +    # The `external` flag indicates whether `mod` has been externally defined,
                +    # as is the case with root namespaces or reopened third-party namespaces.
                +    #
                +    #: (String, Module, external: boolish) -> void
                +    private def define_autoloads_for_dir(dir, mod, external:)
                @@ -480,9 +498,8 @@
                -          basename.delete_suffix!(".rb")
                -          cref = Cref.new(parent, cname_for(basename, abspath))
                -          autoload_file(cref, abspath)
                -        else
                -          if collapse?(abspath)
                -            define_autoloads_for_dir(abspath, parent)
                -          else
                -            cref = Cref.new(parent, cname_for(basename, abspath))
                -            autoload_subdir(cref, abspath)
                +          if basename == @nsfile
                +            if external
                +              cpath = real_mod_name(mod)
                +              location = Object.const_source_location(cpath)&.join(':')
                +              location = nil if location&.empty?
                +              raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cpath, location: location, conflicting_file: abspath)
                +            end
                +            next # Pass if this is a managed namespace, the nsfile was already probed when visiting the parent directory.
                @@ -489,0 +507,7 @@
                +
                +          basename.delete_suffix!('.rb')
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_file(cref, abspath)
                +        else
                +          cref = Cref.new(mod, cname_for(basename, abspath))
                +          visit_subdir(cref, abspath, external:)
                @@ -495 +519,25 @@
                -    private def autoload_subdir(cref, subdir)
                +    private def visit_file(cref, file)
                +      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                +        if @fs.rb_extension?(autoload_path)
                +          if File.basename(autoload_path) == @nsfile && autoload_path_set_by_me_for?(cref)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: file)
                +          end
                +          shadowed_files << file
                +          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                +        else
                +          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                +        end
                +      elsif cref.defined?
                +        shadowed_files << file
                +        if location = cref.location
                +          log { "file #{file} is ignored because #{cref} is already defined in #{location}" }
                +        else
                +          log { "file #{file} is ignored because #{cref} is already defined (unknown location)" }
                +        end
                +      else
                +        define_autoload(cref, file)
                +      end
                +    end
                +
                +    #: (Zeitwerk::Cref, String, external: boolish) -> void
                +    private def visit_subdir(cref, subdir, external:)
                @@ -497,0 +546,6 @@
                +          # The namespace that corresponds to this subdirectory is defined in a
                +          # file, either regular or nsfile. Therefore, a nsfile would be a
                +          # duplication.
                +          if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: autoload_path, conflicting_file: nsfile_abspath)
                +          end
                @@ -499,2 +553 @@
                -          # constant has been found. This means we are dealing with an explicit
                -          # namespace whose definition was seen first.
                +          # constant has been found. This is an explicit namespace.
                @@ -502,3 +555,2 @@
                -          # Registering is idempotent, and we have to keep the autoload pointing
                -          # to the file. This may run again if more directories are found later
                -          # on, no big deal.
                +          # The namespace may be spread over multiple directories and perhaps it
                +          # was already registered, but registering is idempotent, just do it.
                @@ -505,0 +558,3 @@
                +        elsif nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          # Scanning found a matching directory first, and now we saw a nsfile.
                +          promote_namespace_from_implicit_to_explicit(dir: subdir, file: nsfile_abspath, cref: cref)
                @@ -507,3 +561,0 @@
                -        # If the existing autoload points to a file, it has to be preserved, if
                -        # not, it is fine as it is. In either case, we do not need to override.
                -        # Just remember the subdirectory conforms this namespace.
                @@ -512 +564,6 @@
                -        # First time we find this namespace, set an autoload for it.
                +        if nsfile_abspath = @fs.has_exactly_one_nsfile?(cref, subdir)
                +          define_autoload(cref, nsfile_abspath)
                +          register_explicit_namespace(cref)
                +        else
                +          define_autoload(cref, subdir)
                +        end
                @@ -514 +570,0 @@
                -        define_autoload(cref, subdir)
                @@ -519,19 +575 @@
                -        define_autoloads_for_dir(subdir, cref.get)
                -      end
                -    end
                -
                -    #: (Zeitwerk::Cref, String) -> void
                -    private def autoload_file(cref, file)
                -      if autoload_path = cref.autoload? || Registry.inceptions.registered?(cref)
                -        # First autoload for a Ruby file wins, just ignore subsequent ones.
                -        if @fs.rb_extension?(autoload_path)
                -          shadowed_files << file
                -          log { "file #{file} is ignored because #{autoload_path} has precedence" }
                -        else
                -          promote_namespace_from_implicit_to_explicit(dir: autoload_path, file: file, cref: cref)
                -        end
                -      elsif cref.defined?
                -        shadowed_files << file
                -        log { "file #{file} is ignored because #{cref} is already defined" }
                -      else
                -        define_autoload(cref, file)
                +        define_autoloads_for_dir(subdir, cref.get, external:)
                @@ -611 +649 @@
                -        require "pp" # Needed to have pretty_inspect available.
                +        require 'pp' # Needed to have pretty_inspect available.
        lib/zeitwerk/loader/callbacks.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:34.931312897 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/callbacks.rb	2026-05-20 07:03:34.936312865 +0000
                @@ -80 +80 @@
                -        define_autoloads_for_dir(dir, namespace)
                +        define_autoloads_for_dir(dir, namespace, external: false)
        lib/zeitwerk/loader/config.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:34.931312897 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/config.rb	2026-05-20 07:03:34.936312865 +0000
                @@ -3,2 +3,2 @@
                -require "set"
                -require "securerandom"
                +require 'set'
                +require 'securerandom'
                @@ -18,2 +18,2 @@
                -  #   "/Users/fxn/blog/app/channels" => Object,
                -  #   "/Users/fxn/blog/app/adapters" => ActiveJob::QueueAdapters,
                +  #   '/Users/fxn/blog/app/channels' => Object,
                +  #   '/Users/fxn/blog/app/adapters' => ActiveJob::QueueAdapters,
                @@ -31,0 +32,6 @@
                +  # Basename of files that define namespaces. For example, if `nsfile` is
                +  # 'ns.rb', then `foo/ns.rb` defines the `Foo` namespace.
                +  #
                +  #: String?
                +  attr_reader :nsfile
                +
                @@ -53 +59 @@
                -  # glob patterns were expanded. Computed on setup, and recomputed on reload.
                +  # glob patterns were expanded. Computed on setup and recomputed on reload.
                @@ -58,0 +65,8 @@
                +  # Absolute paths of directories that are parents of collapsed directories.
                +  # This is a cache to optimize some tree walks. Computed on setup and
                +  # recomputed on reload.
                +  #
                +  #: Set[String]
                +  attr_reader :collapse_parents
                +  private :collapse_parents
                +
                @@ -84,0 +99 @@
                +  #: () -> void
                @@ -90,0 +106 @@
                +    @nsfile                 = nil
                @@ -94,0 +111 @@
                +    @collapse_parents       = Set.new
                @@ -115 +132 @@
                -      raise Zeitwerk::Error, "root namespaces cannot be anonymous"
                +      raise Zeitwerk::Error, 'root namespaces cannot be anonymous'
                @@ -143,0 +161,12 @@
                +  #: (String?) -> void ! TypeError, ArgumentError
                +  def nsfile=(nsfile)
                +    unless nsfile.nil?
                +      raise TypeError,     'nsfiles must be strings'              unless nsfile.is_a?(String)
                +      raise ArgumentError, 'nsfiles must have .rb extension'      unless @fs.rb_extension?(nsfile)
                +      raise ArgumentError, 'nsfiles must be basenames, not paths' unless File.basename(nsfile) == nsfile
                +      raise ArgumentError, 'nsfiles cannot be hidden'             if @fs.hidden?(nsfile)
                +    end
                +
                +    @nsfile = nsfile
                +  end
                +
                @@ -179 +208 @@
                -        raise Zeitwerk::Error, "cannot enable reloading after setup"
                +        raise Zeitwerk::Error, 'cannot enable reloading after setup'
                @@ -217 +246,5 @@
                -      collapse_dirs.merge(expand_glob_patterns(glob_patterns))
                +      new_collapse_dirs = expand_glob_patterns(glob_patterns)
                +      collapse_dirs.merge(new_collapse_dirs)
                +      new_collapse_dirs.each do |dir|
                +        collapse_parents << File.dirname(dir)
                +      end
                @@ -236,2 +269,2 @@
                -  #   loader.on_load("SomeApiClient") do |klass, _abspath|
                -  #     klass.endpoint = "https://api.dev"
                +  #   loader.on_load('SomeApiClient') do |klass, _abspath|
                +  #     klass.endpoint = 'https://api.dev'
                @@ -248 +281 @@
                -    raise TypeError, "on_load only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_load only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -259 +292 @@
                -  #   loader.on_unload("Country") do |klass, _abspath|
                +  #   loader.on_unload('Country') do |klass, _abspath|
                @@ -271 +304 @@
                -    raise TypeError, "on_unload only accepts strings" unless cpath.is_a?(String) || cpath == :ANY
                +    raise TypeError, 'on_unload only accepts strings' unless cpath.is_a?(String) || cpath == :ANY
                @@ -318,0 +352,10 @@
                +  internal def collapse?(dir)
                +    collapse_dirs.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                +  internal def collapse_parent?(dir)
                +    collapse_parents.member?(dir)
                +  end
                +
                +  #: (String) -> bool
                @@ -331,5 +373,0 @@
                -  #: (String) -> bool
                -  private def collapse?(dir)
                -    collapse_dirs.member?(dir)
                -  end
                -
                @@ -355,0 +394,8 @@
                +  end
                +
                +  #: () -> void
                +  private def recompute_collapse_parents
                +    collapse_parents.clear
                +    collapse_dirs.each do |dir|
                +      collapse_parents << File.dirname(dir)
                +    end
        lib/zeitwerk/loader/eager_load.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:34.931312897 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/eager_load.rb	2026-05-20 07:03:34.936312865 +0000
                @@ -3,4 +3,5 @@
                -  # need to be in `$LOAD_PATH`, absolute file names are used. Ignored and
                -  # shadowed files are not eager loaded. You can opt-out specifically in
                -  # specific files and directories with `do_not_eager_load`, and that can be
                -  # overridden passing `force: true`.
                +  # need to be in `$LOAD_PATH`, absolute file names are used.
                +  #
                +  # Ignored files are not eager loaded. You can opt-out specifically in specific
                +  # files and directories with `do_not_eager_load`, and that can be overridden
                +  # passing `force: true`.
                @@ -14 +15 @@
                -      log { "eager load start" }
                +      log { 'eager load start' }
                @@ -27 +28 @@
                -      log { "eager load end" }
                +      log { 'eager load end' }
                @@ -94 +95 @@
                -        if root_namespace_name.start_with?(mod_name + "::")
                +        if root_namespace_name.start_with?(mod_name + '::')
                @@ -98 +99 @@
                -        elsif mod_name.start_with?(root_namespace_name + "::")
                +        elsif mod_name.start_with?(root_namespace_name + '::')
                @@ -122 +123 @@
                -    file_basename = File.basename(abspath, ".rb")
                +    file_basename = File.basename(abspath)
                @@ -141,2 +141,0 @@
                -    base_cname = cname_for(file_basename, abspath)
                -
                @@ -149,3 +148,8 @@
                -    raise Zeitwerk::Error.new("#{abspath} is shadowed") if shadowed_file?(abspath)
                -
                -    namespace.const_get(base_cname, false)
                +    if file_basename == @nsfile
                +      namespace
                +    elsif shadowed_file?(abspath)
                +      raise Zeitwerk::Error.new("#{abspath} is shadowed")
                +    else
                +      cname = cname_for(file_basename.delete_suffix('.rb'), abspath)
                +      namespace.const_get(cname, false)
                +    end
                @@ -174,6 +178,2 @@
                -          if collapse?(abspath)
                -            queue << [abspath, namespace]
                -          else
                -            cname = cname_for(basename, abspath)
                -            queue << [abspath, namespace.const_get(cname, false)]
                -          end
                +          cname = cname_for(basename, abspath)
                +          queue << [abspath, namespace.const_get(cname, false)]
                @@ -194 +194 @@
                -      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + "::")
                +      suffix = suffix.delete_prefix(real_mod_name(root_namespace) + '::')
                @@ -207 +207 @@
                -    suffix.split("::").each do |segment|
                +    suffix.split('::').each do |segment|
                @@ -210,5 +210 @@
                -          next unless ftype == :directory
                -
                -          if collapse?(abspath)
                -            dirs << abspath
                -          elsif segment == cname_for(basename, abspath).to_s
                +          if ftype == :directory && segment == cname_for(basename, abspath).to_s
        lib/zeitwerk/loader/file_system.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:34.931312897 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/file_system.rb	2026-05-20 07:03:34.936312865 +0000
                @@ -14,0 +15,14 @@
                +  # This method lists directories, filtering out the following:
                +  #
                +  # - Hidden entries.
                +  # - Ignored entries.
                +  # - Files whose extension is not `.rb`.
                +  # - Nested root directories, since they represent separate trees.
                +  # - Subdirectories that (recursively) contain no Ruby files.
                +  #
                +  # If `collapse` is true, collapsed directories are not yielded, instead, the
                +  # method recurses so that the caller gets a conceptually flat listing.
                +  #
                +  # For every entry that is not excluded, `ls` yields its basename, absolute
                +  # path, and file type, which can only be :file or :directory.
                +  #
                @@ -16 +30 @@
                -  def ls(dir)
                +  def ls(dir, collapse: true, &block)
                @@ -27,3 +41,8 @@
                -      if :directory == ftype && !has_at_least_one_ruby_file?(abspath)
                -        @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                -        next
                +      if ftype == :directory
                +        if !has_at_least_one_ruby_file?(abspath)
                +          @loader.__log { "directory #{abspath} is ignored because it has no Ruby files" }
                +          next
                +        elsif collapse && @loader.__collapse?(abspath)
                +          ls(abspath, collapse: collapse, &block)
                +          next
                +        end
                @@ -41 +60,38 @@
                -      break if basename == "/"
                +      break if basename == '/'
                +    end
                +  end
                +
                +  # Returns the absolute path to an nsfile in `dir`, if there is exactly one. If
                +  # there is none, it returns `nil`.
                +  #
                +  # This method accounts for collapsed directories, which conceptually allow for
                +  # multiple nsfiles. If two are found, Zeitwerk::ConflictingNamespaceDefinitionError is raised.
                +  #
                +  #: (Zeitwerk::Cref, String) -> String? ! Zeitwerk::ConflictingNamespaceDefinitionError
                +  def has_exactly_one_nsfile?(cref, dir)
                +    return unless @loader.nsfile
                +
                +    # When `dir` does not have any collapsed directories a simple lookup
                +    # suffices. This is a common case worth optimizing.
                +    unless @loader.__collapse_parent?(dir)
                +      nsfile_abspath = File.join(dir, @loader.nsfile)
                +      if File.exist?(nsfile_abspath) && !@loader.__ignored_path?(nsfile_abspath)
                +        return nsfile_abspath
                +      end
                +      return
                +    end
                +
                +    nsfile = nil
                +
                +    to_visit = [dir]
                +    while (dir = to_visit.shift)
                +      relevant_dir_entries(dir) do |basename, abspath, ftype|
                +        if ftype == :file && basename == @loader.nsfile
                +          if nsfile
                +            raise Zeitwerk::ConflictingNamespaceDefinitionError.new(cref.path, location: nsfile, conflicting_file: abspath)
                +          end
                +          nsfile = abspath
                +        elsif ftype == :directory && @loader.__collapse?(abspath)
                +          to_visit << abspath
                +        end
                +      end
                @@ -42,0 +99,2 @@
                +
                +    nsfile
                @@ -58 +116 @@
                -    path.end_with?(".rb")
                +    path.end_with?('.rb')
                @@ -68 +126 @@
                -    basename.start_with?(".")
                +    basename.start_with?('.')
                @@ -83 +141 @@
                -        return true if :file == ftype
                +        return true if ftype == :file
                @@ -99,12 +157,3 @@
                -      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
                +      if ftype == :file
                +        yield basename, abspath, ftype
                +      else
                @@ -138 +187 @@
                -        elsif :directory == ftype
                +        elsif ftype == :directory
                @@ -141 +190 @@
                -        elsif :link == ftype
                +        elsif ftype == :link
                @@ -158,3 +207 @@
                -          if dir?(abspath)
                -            yield basename, abspath, :directory
                -          end
                +          yield basename, abspath, :directory if dir?(abspath)
        lib/zeitwerk/loader/helpers.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:34.931312897 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/loader/helpers.rb	2026-05-20 07:03:34.936312865 +0000
                @@ -15 +15 @@
                -    if cname.include?("::")
                +    if cname.include?('::')
                @@ -28 +28 @@
                -      path_type = @fs.rb_extension?(abspath) ? "file" : "directory"
                +      path_type = @fs.rb_extension?(abspath) ? 'file' : 'directory'
        lib/zeitwerk/registry.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/registry.rb	2026-05-20 07:03:34.931312897 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/registry.rb	2026-05-20 07:03:34.936312865 +0000
                @@ -5,4 +5,4 @@
                -    require_relative "registry/autoloads"
                -    require_relative "registry/explicit_namespaces"
                -    require_relative "registry/inceptions"
                -    require_relative "registry/loaders"
                +    require_relative 'registry/autoloads'
                +    require_relative 'registry/explicit_namespaces'
                +    require_relative 'registry/inceptions'
                +    require_relative 'registry/loaders'
                @@ -57,2 +57,2 @@
                -              new_root_dir_slash = new_root_dir + "/"
                -              existing_root_dir_slash = existing_root_dir + "/"
                +              new_root_dir_slash = new_root_dir + '/'
                +              existing_root_dir_slash = existing_root_dir + '/'
        lib/zeitwerk/registry/loaders.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:34.932312891 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/registry/loaders.rb	2026-05-20 07:03:34.936312865 +0000
                @@ -9,2 +9,2 @@
                -    def each(&)
                -      @loaders.each(&)
                +    def each(&block)
                +      @loaders.each(&block)
        lib/zeitwerk/version.rb
                --- /tmp/d20260520-403-25zcfh/zeitwerk-2.7.5/lib/zeitwerk/version.rb	2026-05-20 07:03:34.932312891 +0000
                +++ /tmp/d20260520-403-25zcfh/zeitwerk-2.8.1/lib/zeitwerk/version.rb	2026-05-20 07:03:34.936312865 +0000
                @@ -5 +5 @@
                -  VERSION = "2.7.5"
                +  VERSION = '2.8.1'

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

---
updated-dependencies:
- dependency-name: zeitwerk
  dependency-version: 2.8.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot force-pushed the dependabot/bundler/zeitwerk-2.8.1 branch from 1bb6541 to 9de8a18 Compare May 20, 2026 14:38
@dependabot @github

dependabot Bot commented on behalf of github May 25, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #990.

@dependabot dependabot Bot closed this May 25, 2026
@dependabot dependabot Bot deleted the dependabot/bundler/zeitwerk-2.8.1 branch May 25, 2026 02:53
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