Module: Axn::Tools::Registry

Extended by:
Registry
Included in:
Registry
Defined in:
lib/axn/tools/registry.rb

Overview

Process-global tool registry: the registered adapter keys and every include-Axn class.

Instance Method Summary collapse

Instance Method Details

#adapter_config_source(adapter) ⇒ Object



28
29
30
# File 'lib/axn/tools/registry.rb', line 28

def adapter_config_source(adapter)
  _adapter_sources[adapter.to_sym]
end

#adaptersObject



24
25
26
# File 'lib/axn/tools/registry.rb', line 24

def adapters
  _adapter_sources.keys.to_set
end

#all_classesObject

Only currently-defined, named classes survive. Every entry that isn't _currently_defined? is deleted from _classes here, releasing its strong ref so a process-global Set can't pin dead classes forever. That covers both cases _currently_defined? rejects: a stale NAMED reference left by a Zeitwerk reload (the reloaded constant points at a fresh object), and a transient anonymous class (name nil) that never got a constant. An anonymous class can never be a usable tool anyway (no stable tool_name, no const_source_location for tool_root membership), and members runs at adapter setup — well after class definition — so the "anonymous now, named later" window is effectively never open at enumeration. Iterates a snapshot (_classes.to_a) so a mid-enumeration registration can't corrupt the backing Set and deleting while walking is safe.



52
53
54
55
56
57
58
59
60
61
62
# File 'lib/axn/tools/registry.rb', line 52

def all_classes
  live = []
  _classes.to_a.each do |klass|
    if _currently_defined?(klass)
      live << klass
    else
      _classes.delete(klass)
    end
  end
  live
end

#ensure_loaded!Object

Ensures tool classes under each adapter's tool roots are loaded before enumeration. Under Rails, unless eager-loading has already completed, hands each existing tool dir to the main Zeitwerk loader via eager_load_dir; outside Rails, requires every .rb file under each existing tool dir individually. Both branches rescue StandardError, ScriptError — ScriptError covers SyntaxError, LoadError, and NotImplementedError — so any load failure of one unit (a malformed file, a missing require, a raising initializer) is isolated and warn-logged rather than aborting enumeration. Isolation granularity differs by path: outside Rails, each require is rescued independently, so one bad FILE is logged at warn and skipped without affecting its siblings. Under Rails, eager_load_dir loads a DIRECTORY as a single unit — Zeitwerk has no public API to load or require a managed file in isolation — so a file that raises aborts the rest of that directory's files (logged at warn), while every other tool root directory still loads independently.



123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/axn/tools/registry.rb', line 123

def ensure_loaded!
  dirs = _all_adapter_dirs.select { |dir| File.directory?(dir) }
  return if dirs.empty?

  if _rails_app?
    # `config.eager_load` only says Rails INTENDS to eager-load; that phase runs late in boot
    # (after config/initializers). Skip the on-demand load only once the app has finished
    # initializing (eager-load has actually run), so an `Axn::Tools.for` call from within an
    # initializer still loads the tool dirs on demand.
    return if Rails.application.config.eager_load &&
              Rails.application.respond_to?(:initialized?) && Rails.application.initialized?

    loader = Rails.autoloaders.main
    # The engine only pushes app/actions into Zeitwerk `after: :load_config_initializers`
    # (see Axn::RailsIntegration::Engine), so an `Axn::Tools.for` call from within a
    # `config/initializers` file runs BEFORE that hook — a configured tool dir can exist on
    # disk yet not be one Zeitwerk manages. `eager_load_dir` on an unmanaged dir would just
    # raise and get rescued below, silently yielding an empty/partial tool list. We don't push
    # dirs ourselves here (that's the engine's job, with its own namespace) — instead we check
    # Zeitwerk's own managed-root list (`loader.dirs`) up front and warn loudly so the caller
    # knows discovery may be incomplete, rather than degrading silently.
    managed_roots = loader.respond_to?(:dirs) ? loader.dirs : nil
    dirs.each { |dir| _eager_load_rails_dir(loader, dir, managed_roots) }
  else
    dirs.each do |dir|
      Dir.glob(File.join(dir, "**", "*.rb")).each do |file|
        # Snapshot _classes before the require so that if the file registers an Axn class (via
        # include/inherited) and THEN raises later in the same file, we can roll those
        # registrations back — otherwise a "skipped" file would still leak its classes into
        # `members`. The loop is single-threaded, so a before/after diff is exact. Scope the
        # rollback to classes SOURCED FROM this file: a dependency the file `require`d before
        # raising was registered in the same window but belongs to its own (valid) file, and
        # Ruby marks that file loaded so a later glob iteration would no-op — dropping it here
        # would leave the valid tool's constant defined yet permanently absent from _classes.
        before = _classes.dup
        require file
      rescue StandardError, ScriptError => e
        expanded = File.expand_path(file)
        _rollback_registrations(before) { |src| src == expanded }
        Axn.config.logger.warn do
          "[Axn] tool file skipped (#{_rendered_path(file)}): #{Axn::Internal::Rendering.class_name(e)}: " \
            "#{Axn::Internal::Rendering.exception_message(e)}"
        end
      end
    end
  end
rescue StandardError => e
  Axn.config.logger.warn do
    "[Axn] tool eager-load skipped: #{Axn::Internal::Rendering.class_name(e)}: #{Axn::Internal::Rendering.exception_message(e)}"
  end
end

#member?(klass, adapter) ⇒ Boolean

Membership = (directory grant ∪ declaration grant) − except. Directory grant: adapters whose configured tool_roots contain the class's source file. Declaration grant: :all (every adapter), or the explicit adapter list, or a tolerant configure() bag. tool false and an excepted adapter both short-circuit to non-membership.

Returns:

  • (Boolean)


179
180
181
182
183
184
185
186
187
188
# File 'lib/axn/tools/registry.rb', line 179

def member?(klass, adapter)
  return false unless klass.respond_to?(:_tool_declaration)

  decl = klass._tool_declaration
  return false if decl == false
  return false if klass._tool_except.include?(adapter)

  declared_grant = decl == :all || (decl.is_a?(Array) && decl.include?(adapter))
  declared_grant || _under_adapter_root?(klass, adapter) || _declares_adapter_config?(klass, adapter)
end

#members(adapter, all_versions: false) ⇒ Object

adapter is vetted by Axn::Tools.for, which is how an adapter reaches this; a direct call here trusts its caller, and an unregistered key enumerates nothing rather than naming the mistake.



76
77
78
79
80
81
82
83
84
85
86
87
88
# File 'lib/axn/tools/registry.rb', line 76

def members(adapter, all_versions: false)
  ensure_loaded!
  candidates = all_classes.select { |klass| member?(klass, adapter) }
  _assert_versioned_naming!(candidates)
  groups = _version_groups(candidates, adapter)
  if all_versions
    # Deterministic: by tool_name, then ascending version within each group.
    groups.sort_by(&:tool_name).flat_map(&:all)
  else
    # Latest per tool_name. Names are distinct after collapsing, so sort_by is tie-free.
    groups.map(&:latest).sort_by { |klass| klass.tool_name(adapter) }
  end
end

#register_adapter(key, config_source = nil) ⇒ Object

A nil source on RE-registration keeps the existing source: the idempotent "ensure this adapter is registered" call (no source) must not wipe a source an adapter gem already supplied, which would strip its tool_roots and drop every directory-granted tool. A non-nil source always updates (last-wins); a first-time nil registration stores nil (a declaration-driven adapter with no directory roots).



17
18
19
20
21
22
# File 'lib/axn/tools/registry.rb', line 17

def register_adapter(key, config_source = nil)
  key = key.to_sym
  return if config_source.nil? && _adapter_sources.key?(key)

  _adapter_sources[key] = config_source
end

#register_class(klass) ⇒ Object

Called at include-Axn time (direct include) and inherited time (subclasses) for every action class. Idempotent: the backing Set drops a class already present, so a class reachable via more than one path is never enumerated twice by members.



39
40
41
# File 'lib/axn/tools/registry.rb', line 39

def register_class(klass)
  _classes << klass
end

#reset_adapters!Object



32
33
34
# File 'lib/axn/tools/registry.rb', line 32

def reset_adapters!
  @adapter_sources = {}
end

#tool_classesObject

Every registered class that is a tool for ANY adapter, loaded dirs included. Separate from members because validation is adapter-agnostic: a contract is valid or not, and asking per adapter would project a class once per adapter it belongs to. Version groups are not collapsed either — each version is its own class with its own contract, so each is worth validating.



68
69
70
71
72
# File 'lib/axn/tools/registry.rb', line 68

def tool_classes
  ensure_loaded!
  keys = adapters
  all_classes.select { |klass| keys.any? { |adapter| member?(klass, adapter) } }
end

#version_group(adapter, tool_name) ⇒ Object

The resolved version group for one logical tool under adapter, or nil when nothing matches. Entry point for an adapter that needs one tool's versions by name (a path-routing HTTP surface resolving /{tool_name}/v{n}) rather than the whole enumeration.

adapter is vetted by Axn::Tools.versions, which is how an adapter reaches this; a direct call here trusts its caller, and an unregistered key resolves nothing rather than naming the mistake.



96
97
98
99
100
101
102
103
104
105
106
107
108
# File 'lib/axn/tools/registry.rb', line 96

def version_group(adapter, tool_name)
  ensure_loaded!
  target = tool_name.to_s
  candidates = all_classes.select { |klass| member?(klass, adapter) && klass.tool_name(adapter) == target }
  return nil if candidates.empty?

  # Validate the MATCHED members so this lookup never disagrees with `members`: a malformed
  # ::Vn member whose (explicit or derived) name matches `target` raises here too, exactly as
  # it would in `members`. Scoped to the matched set, so an unrelated malformed tool under a
  # different name can't derail the lookup.
  _assert_versioned_naming!(candidates)
  VersionGroup.new(adapter:, tool_name: target, members: candidates)
end