woods 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -7
  3. data/CONTRIBUTING.md +134 -19
  4. data/README.md +1 -1
  5. data/docs/AGENT_GUIDE.md +19 -0
  6. data/docs/AGENT_SETUP.md +22 -2
  7. data/docs/BACKEND_MATRIX.md +7 -0
  8. data/docs/CLIENT_HOOKS.md +6 -0
  9. data/docs/CONFIGURATION_REFERENCE.md +133 -25
  10. data/docs/CONSOLE_MCP_SETUP.md +82 -30
  11. data/docs/EMBEDDING_MODELS.md +16 -19
  12. data/docs/EXTRACTOR_REFERENCE.md +219 -21
  13. data/docs/FAQ.md +11 -25
  14. data/docs/GETTING_STARTED.md +7 -1
  15. data/docs/INCREMENTAL_EXTRACTION.md +261 -19
  16. data/docs/INDEX_LAYOUT.md +5 -0
  17. data/docs/INTERNALS.md +9 -0
  18. data/docs/MCP_HTTP_TRANSPORT.md +20 -15
  19. data/docs/MCP_SERVERS.md +87 -8
  20. data/docs/MCP_TOOL_COOKBOOK.md +13 -55
  21. data/docs/NOTION_INTEGRATION.md +7 -1
  22. data/docs/PUBLISHED_INDEX.md +6 -0
  23. data/docs/README.md +6 -1
  24. data/docs/RETRIEVAL_GUIDE.md +17 -0
  25. data/docs/SOURCE_FRESHNESS.md +157 -5
  26. data/docs/TOKEN_BENCHMARK.md +10 -18
  27. data/docs/TROUBLESHOOTING.md +70 -14
  28. data/docs/UNBLOCKED_INTEGRATION.md +60 -8
  29. data/docs/UPGRADING_TO_2.md +153 -38
  30. data/docs/WATCH_DAEMON.md +97 -14
  31. data/exe/woods-console-mcp +2 -2
  32. data/lib/generators/woods/templates/woods.rb.tt +2 -1
  33. data/lib/tasks/woods.rake +23 -7
  34. data/lib/tasks/woods_checks.rake +2 -2
  35. data/lib/woods/agent_configuration/cli.rb +1 -1
  36. data/lib/woods/agent_configuration/layout.rb +16 -2
  37. data/lib/woods/agent_configuration/plan.rb +13 -3
  38. data/lib/woods/agent_configuration/planner_validation.rb +4 -2
  39. data/lib/woods/agent_configuration/preflight.rb +5 -3
  40. data/lib/woods/builder.rb +17 -57
  41. data/lib/woods/cache/cache_middleware.rb +56 -30
  42. data/lib/woods/chunking/contributor_chunks.rb +119 -0
  43. data/lib/woods/chunking/semantic_chunker.rb +44 -21
  44. data/lib/woods/console/connection_manager.rb +56 -3
  45. data/lib/woods/console/embedded_executor.rb +30 -5
  46. data/lib/woods/console/rack_middleware.rb +29 -1
  47. data/lib/woods/dependency_graph.rb +34 -10
  48. data/lib/woods/embedding/fake.rb +12 -0
  49. data/lib/woods/embedding/indexer.rb +195 -98
  50. data/lib/woods/embedding/input_budget.rb +67 -0
  51. data/lib/woods/embedding/openai.rb +70 -20
  52. data/lib/woods/embedding/provider.rb +37 -25
  53. data/lib/woods/embedding/text_preparer.rb +76 -32
  54. data/lib/woods/embedding/token_counter.rb +18 -81
  55. data/lib/woods/embedding/vector_configuration.rb +48 -0
  56. data/lib/woods/extraction_identities.rb +175 -0
  57. data/lib/woods/extractor.rb +304 -107
  58. data/lib/woods/extractors/action_cable_extractor.rb +8 -3
  59. data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
  60. data/lib/woods/extractors/class_declarations.rb +121 -0
  61. data/lib/woods/extractors/configuration_extractor.rb +11 -3
  62. data/lib/woods/extractors/declaration_ancestry.rb +92 -0
  63. data/lib/woods/extractors/event_extractor.rb +8 -0
  64. data/lib/woods/extractors/graphql_extractor.rb +134 -77
  65. data/lib/woods/extractors/job_extractor.rb +5 -1
  66. data/lib/woods/extractors/lib_extractor.rb +132 -15
  67. data/lib/woods/extractors/mailer_extractor.rb +3 -5
  68. data/lib/woods/extractors/manager_extractor.rb +7 -21
  69. data/lib/woods/extractors/migration_declaration.rb +87 -0
  70. data/lib/woods/extractors/migration_extractor.rb +5 -39
  71. data/lib/woods/extractors/phlex_extractor.rb +6 -2
  72. data/lib/woods/extractors/policy_extractor.rb +9 -5
  73. data/lib/woods/extractors/poro_extractor.rb +112 -53
  74. data/lib/woods/extractors/pundit_extractor.rb +11 -6
  75. data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
  76. data/lib/woods/extractors/serializer_extractor.rb +34 -22
  77. data/lib/woods/extractors/shared_utility_methods.rb +18 -1
  78. data/lib/woods/extractors/source_nesting.rb +142 -106
  79. data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
  80. data/lib/woods/extractors/state_machine_extractor.rb +46 -40
  81. data/lib/woods/extractors/view_component_extractor.rb +9 -7
  82. data/lib/woods/flow_assembler.rb +4 -1
  83. data/lib/woods/generation.rb +25 -0
  84. data/lib/woods/hooks/context_hint.rb +7 -2
  85. data/lib/woods/mcp/bootstrapper.rb +33 -7
  86. data/lib/woods/mcp/config_resolver.rb +26 -7
  87. data/lib/woods/mcp/index_reader.rb +125 -24
  88. data/lib/woods/mcp/index_reader_pinning.rb +16 -0
  89. data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
  90. data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
  91. data/lib/woods/mcp/search_results.rb +7 -1
  92. data/lib/woods/mcp/server.rb +24 -4
  93. data/lib/woods/module_reconciliation.rb +151 -0
  94. data/lib/woods/path_dispatcher.rb +7 -2
  95. data/lib/woods/rake_helpers.rb +43 -11
  96. data/lib/woods/release.rb +1 -1
  97. data/lib/woods/resilience/index_validator.rb +8 -3
  98. data/lib/woods/resilience/retryable_provider.rb +18 -1
  99. data/lib/woods/resolved_config.rb +68 -8
  100. data/lib/woods/retrieval/context_assembler.rb +3 -3
  101. data/lib/woods/retrieval/lexical_assembler.rb +3 -2
  102. data/lib/woods/retrieval/scope.rb +18 -2
  103. data/lib/woods/retrieval/source_evidence.rb +14 -2
  104. data/lib/woods/source_contributor_validation.rb +78 -0
  105. data/lib/woods/source_contributors.rb +116 -0
  106. data/lib/woods/source_inputs/handoff.rb +37 -0
  107. data/lib/woods/source_inputs/launcher.rb +53 -13
  108. data/lib/woods/source_inputs/manifest.rb +84 -3
  109. data/lib/woods/source_inputs/private_key.rb +44 -12
  110. data/lib/woods/source_inputs/scanner.rb +98 -27
  111. data/lib/woods/source_inputs/scopes.rb +1 -1
  112. data/lib/woods/source_inputs/session.rb +147 -15
  113. data/lib/woods/source_inputs/stable_reader.rb +127 -0
  114. data/lib/woods/source_inputs/status.rb +40 -8
  115. data/lib/woods/source_inputs/verifier.rb +28 -5
  116. data/lib/woods/source_path_encoding.rb +33 -0
  117. data/lib/woods/source_references/cache.rb +284 -0
  118. data/lib/woods/source_references/collector.rb +120 -0
  119. data/lib/woods/source_references/extraction.rb +185 -0
  120. data/lib/woods/source_references/inputs.rb +134 -0
  121. data/lib/woods/source_references/parser_adapter.rb +134 -0
  122. data/lib/woods/source_references/pass.rb +152 -0
  123. data/lib/woods/source_references/prism_adapter.rb +116 -0
  124. data/lib/woods/source_references/registry.rb +178 -0
  125. data/lib/woods/source_references/runtime_lookup.rb +127 -0
  126. data/lib/woods/source_references/value_class.rb +82 -0
  127. data/lib/woods/storage/metadata_store.rb +4 -1
  128. data/lib/woods/storage/qdrant.rb +2 -2
  129. data/lib/woods/unblocked/client.rb +12 -7
  130. data/lib/woods/unblocked/document_builder.rb +4 -1
  131. data/lib/woods/unblocked/exporter.rb +127 -37
  132. data/lib/woods/unblocked/sync_manifest.rb +137 -21
  133. data/lib/woods/unblocked/uri_migration.rb +105 -0
  134. data/lib/woods/util/host_guard.rb +3 -2
  135. data/lib/woods/version.rb +1 -1
  136. data/lib/woods/watch/catch_up.rb +138 -0
  137. data/lib/woods/watch/claim_lease.rb +150 -0
  138. data/lib/woods/watch/cli.rb +26 -2
  139. data/lib/woods/watch/daemon.rb +80 -59
  140. data/lib/woods/watch/installation/options.rb +1 -1
  141. data/lib/woods/watch/installation/receipt.rb +6 -1
  142. data/lib/woods/watch/managed_child.rb +1 -1
  143. data/lib/woods/watch/supervisor.rb +1 -1
  144. data/lib/woods/watch/tree_scan.rb +14 -2
  145. data/plugin/.claude-plugin/plugin.json +1 -1
  146. data/plugin/hooks/adapters/normalize.rb +3 -2
  147. data/plugin/hooks/woods-input-rules.sh +4 -0
  148. data/plugin/hooks/woods-refresh.sh +15 -7
  149. data/plugin/hooks/woods-session-start.sh +60 -3
  150. data/plugin/skills/woods-diagnose/SKILL.md +334 -11
  151. data/plugin/skills/woods-investigate/SKILL.md +11 -0
  152. data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
  153. data/plugin/skills/woods-setup/SKILL.md +53 -6
  154. metadata +32 -5
@@ -4,6 +4,7 @@ require_relative '../source_inputs/consumer_errors'
4
4
 
5
5
  require_relative 'shared_utility_methods'
6
6
  require_relative 'shared_dependency_scanner'
7
+ require_relative '../source_references/runtime_lookup'
7
8
 
8
9
  module Woods
9
10
  module Extractors
@@ -61,24 +62,11 @@ module Woods
61
62
 
62
63
  # Class-based discovery for loaded gems
63
64
  seen = units.to_set(&:identifier)
64
- BASE_CLASSES.each_key do |base_class_name|
65
- base_class = begin
66
- base_class_name.constantize
67
- rescue NameError
68
- nil
69
- end
70
- next unless base_class
71
-
72
- base_class.descendants.each do |klass|
73
- next if klass.name.nil?
74
- next if seen.include?(klass.name)
75
-
76
- unit = extract_serializer_class(klass, base_class_name)
77
- if unit
78
- units << unit
79
- seen << unit.identifier
80
- end
81
- end
65
+ discoverable_classes.each do |klass|
66
+ next if seen.include?(klass.name)
67
+
68
+ unit = extract_serializer_class(klass)
69
+ units << unit if unit
82
70
  end
83
71
 
84
72
  units.compact
@@ -112,15 +100,22 @@ module Woods
112
100
  nil
113
101
  end
114
102
 
115
- private
103
+ # Current, named descendants of the supported runtime serializer bases.
104
+ # Stale class objects retained after Rails reload do not own their name.
105
+ # @return [Array<Class>]
106
+ def discoverable_classes
107
+ BASE_CLASSES.keys.filter_map(&:safe_constantize).flat_map(&:descendants).uniq.select do |klass|
108
+ runtime_base_for(klass)
109
+ end
110
+ end
116
111
 
117
112
  # Extract a serializer from its class (runtime introspection)
118
113
  #
119
114
  # @param klass [Class] The serializer class
120
- # @param base_class_name [String] Name of the detected base class
121
115
  # @return [ExtractedUnit, nil] The extracted unit
122
- def extract_serializer_class(klass, base_class_name)
123
- return nil if klass.name.nil?
116
+ def extract_serializer_class(klass)
117
+ base_class_name = runtime_base_for(klass)
118
+ return nil unless base_class_name
124
119
 
125
120
  file_path = source_file_for(klass)
126
121
  source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
@@ -142,6 +137,23 @@ module Woods
142
137
  nil
143
138
  end
144
139
 
140
+ private
141
+
142
+ # The live constant and a supported ancestor jointly establish ownership.
143
+ # @param klass [Class]
144
+ # @return [String, nil] supported base name for a current serializer class
145
+ def runtime_base_for(klass)
146
+ return nil unless klass.is_a?(Class) && klass.name
147
+
148
+ lookup = SourceReferences::RuntimeLookup.new
149
+ return nil unless lookup.call("::#{klass.name}", allow_private: true)[:value].equal?(klass)
150
+
151
+ BASE_CLASSES.keys.find do |name|
152
+ base = name.safe_constantize
153
+ base && klass < base
154
+ end
155
+ end
156
+
145
157
  # ──────────────────────────────────────────────────────────────────────
146
158
  # Class Discovery
147
159
  # ──────────────────────────────────────────────────────────────────────
@@ -54,7 +54,24 @@ module Woods
54
54
  def app_source?(path, app_root)
55
55
  return false unless path
56
56
 
57
- path.start_with?(app_root) && !path.include?('/vendor/') && !path.include?('/node_modules/')
57
+ prefix = File.expand_path(app_root.to_s).delete_suffix(File::SEPARATOR) + File::SEPARATOR
58
+ absolute = File.expand_path(path.to_s)
59
+ return false unless absolute.start_with?(prefix)
60
+
61
+ relative = absolute.delete_prefix(prefix)
62
+ relative.split(File::SEPARATOR).none? { |part| %w[vendor node_modules].include?(part) }
63
+ end
64
+
65
+ # Whether a resolved source belongs to this application and is a file.
66
+ # Framework consumers may still use resolve_source_location's external
67
+ # fallback; app-only component families apply this narrower final gate.
68
+ #
69
+ # @param path [String, nil] resolved source path
70
+ # @return [Boolean]
71
+ def app_source_file?(path)
72
+ return false unless path
73
+
74
+ app_source?(path, Rails.root.to_s) && File.file?(path)
58
75
  end
59
76
 
60
77
  # Resolve the source file for a class using reliable introspection,
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'line_neutralizer'
4
+ require 'prism'
4
5
 
5
6
  module Woods
6
7
  module Extractors
@@ -15,19 +16,13 @@ module Woods
15
16
  # Wrong identifiers collapse cross-namespace same-named classes onto one
16
17
  # graph node and leave edges targeting the real constant dangling.
17
18
  #
18
- # This module scans source line by line tracking nesting *by position*:
19
- # a stack of open +module+/+class+ declarations that pops on the matching
20
- # +end+, using the same depth-tracking discipline as
21
- # RakeTaskExtractor#block_opener? (the house pattern for what counts as a
22
- # block opener). Compact declarations (`class Billing::Payment`) keep
23
- # their qualified segments.
24
- #
25
- # The heuristic is regex-based, not an AST. Pathological constructs
26
- # (heredocs containing keywords, keywords inside string literals) can
27
- # unbalance the depth count — the accepted house tradeoff, shared with
28
- # RakeTaskExtractor: declarations and their +end+s pair up for
29
- # conventionally formatted files. Assignment-form conditionals
30
- # (`x = if ...`) are counted; see {#block_opener?}.
19
+ # Prism reads complete source so declarations retain their lexical nesting
20
+ # on a single line and literal text cannot supply a declaration. Compact
21
+ # declarations (`class Billing::Payment`) keep their qualified segments.
22
+ # Conditional declarations remain source candidates, not proof that a
23
+ # runtime branch executed. Method and singleton-class bodies do not name
24
+ # ordinary owners. Invalid source retains the legacy line-based class scan;
25
+ # module ownership requires a successful whole-source parse.
31
26
  #
32
27
  # @example Qualifying the first class declaration
33
28
  # qualified_first_class_name("module Billing\n class Payment\n end\nend")
@@ -100,7 +95,7 @@ module Woods
100
95
  # silently dropped all but one.
101
96
  #
102
97
  # This method computes the expected constant path — from
103
- # +Rails.autoloaders.main+ when a Rails autoloader is up, from the
98
+ # the owning Rails autoloader (main or once) when available, from the
104
99
  # same path-to-constant convention offline otherwise — and returns the
105
100
  # first declaration (class or module) whose qualified name equals it.
106
101
  # When the path is not managed, or no declaration matches (an
@@ -121,12 +116,11 @@ module Woods
121
116
 
122
117
  # Fully-qualified name of the file's primary module.
123
118
  #
124
- # Follows the initial chain of module declarations — each opening
125
- # directly inside the previous one, with only blank/comment lines
126
- # between — and returns the chain joined with `::`. Prelude lines
127
- # before the first module (requires, magic comments, guards) are
128
- # skipped. The chain stops at the first body content (methods,
129
- # `extend`, a class, a self-terminated inner module) and never descends
119
+ # Follows namespace-only wrappers, each containing a sole nonempty
120
+ # module, and returns the chain joined with `::`. Prelude statements
121
+ # before the first module (requires, magic comments, guards) are skipped.
122
+ # The chain stops at body content (methods, `extend`, a class, an empty
123
+ # inner module) and never descends
130
124
  # into mixin plumbing modules ({MIXIN_INNER_MODULES}), so `module
131
125
  # Trackable; module ClassMethods` names +Trackable+ while `module
132
126
  # Gateway; module Stripe; module Refundable` names
@@ -138,34 +132,34 @@ module Woods
138
132
  # @return [String, nil] Qualified module name, or nil when the source
139
133
  # opens no module
140
134
  def qualified_outer_module_name(source)
141
- chain = []
142
-
143
- each_significant_line(source) do |stripped|
144
- decl = DECLARATION_PATTERN.match(stripped)
135
+ parsed = Prism.parse(source)
136
+ return unless parsed.success?
145
137
 
146
- unless decl
147
- next if chain.empty? # prelude before any module (requires, guards)
138
+ each_syntax_declaration(parsed.value) do |kind, name, _qualified, node|
139
+ return unless kind == 'module'
140
+ return if name.split('::').any? { |part| MIXIN_INNER_MODULES.include?(part) }
141
+ next if !node.body && node.location.start_line == node.location.end_line
148
142
 
149
- break # body content — the chain is complete
150
- end
151
-
152
- break unless decl[1] == 'module'
153
- break if decl[2].split('::').any? { |seg| MIXIN_INNER_MODULES.include?(seg) }
143
+ return primary_module_chain(node) || name
144
+ end
154
145
 
155
- unless block_opener?(stripped)
156
- # Self-terminated one-liner (`module Foo; end`): encloses nothing.
157
- # Before the chain starts it is a sibling prelude (skip it);
158
- # afterwards it is body content (chain complete).
159
- break unless chain.empty?
146
+ nil
147
+ end
160
148
 
161
- next
162
- end
149
+ # Only a sole nonempty module is a namespace wrapper. Parse the complete
150
+ # source before calling this so heredoc bodies remain literal text.
151
+ def primary_module_chain(node)
152
+ return unless node.is_a?(Prism::ModuleNode) && node.body
163
153
 
164
- chain << decl[2]
165
- end
154
+ name = DECLARATION_PATTERN.match(node.location.slice)&.[](2)
155
+ return if !name || name.split('::').any? { |part| MIXIN_INNER_MODULES.include?(part) }
156
+ return name unless node.body.is_a?(Prism::StatementsNode) # rescue/ensure belongs to this owner
166
157
 
167
- chain.empty? ? nil : chain.join('::')
158
+ statements = node.body.body
159
+ nested = primary_module_chain(statements.first) if statements.one?
160
+ [name, nested].compact.join('::')
168
161
  end
162
+ private :primary_module_chain
169
163
 
170
164
  # Check if a line opens a new block (do...end, def...end, etc.).
171
165
  #
@@ -185,17 +179,22 @@ module Woods
185
179
 
186
180
  private
187
181
 
188
- # Yield every module/class declaration with its position-aware
189
- # qualified name. The stack discipline is the one documented on the
190
- # module: declarations open frames, {#block_opener?} opens anonymous
191
- # (depth-only) frames, and {END_LINE}-matching lines pop them.
182
+ # Yield named declarations from complete source. Retain the tolerant
183
+ # line scanner for invalid/incomplete source, where Prism cannot provide
184
+ # a complete syntax tree.
192
185
  #
193
186
  # @param source [String] Ruby source code
194
187
  # @yieldparam kind [String] 'module' or 'class'
195
188
  # @yieldparam name [String] The declared (possibly compact) name
196
189
  # @yieldparam qualified [String] Enclosing stack joined with the name
197
190
  # @return [void]
198
- def each_declaration(source)
191
+ def each_declaration(source, &block)
192
+ parsed = Prism.parse(source)
193
+ if parsed.success?
194
+ each_syntax_declaration(parsed.value, &block)
195
+ return
196
+ end
197
+
199
198
  stack = []
200
199
 
201
200
  each_significant_line(source) do |stripped|
@@ -213,6 +212,25 @@ module Woods
213
212
  end
214
213
  end
215
214
 
215
+ # Walk syntactic scopes without evaluating conditionals or interpreting
216
+ # literal contents as Ruby. Declarations under method/singleton scopes
217
+ # cannot establish the ordinary constant ownership used by these units.
218
+ def each_syntax_declaration(node, namespace = [], &block)
219
+ return unless node
220
+ return if node.is_a?(Prism::DefNode) || node.is_a?(Prism::SingletonClassNode)
221
+
222
+ if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
223
+ declaration = DECLARATION_PATTERN.match(node.location.slice)
224
+ return unless declaration
225
+
226
+ nesting = [*namespace, declaration[2]]
227
+ yield declaration[1], declaration[2], nesting.join('::'), node
228
+ each_syntax_declaration(node.body, nesting, &block)
229
+ else
230
+ node.compact_child_nodes.each { |child| each_syntax_declaration(child, namespace, &block) }
231
+ end
232
+ end
233
+
216
234
  # First declaration whose qualified name equals +expected+.
217
235
  #
218
236
  # @param expected [String] The Zeitwerk-expected constant path
@@ -233,16 +251,16 @@ module Woods
233
251
  # is not under a managed root
234
252
  def managed_constant_path(file_path)
235
253
  file_path = File.expand_path(file_path.to_s)
236
- root, owner, foreign_root = managed_root_for(file_path)
254
+ root, owner, loader, foreign_root = managed_root_for(file_path)
237
255
  return nil unless root
238
256
 
239
- return loader_expected_cpath(file_path) if owner == :loader
240
- return foreign_loader_constant_path(file_path, root, foreign_root) if owner == :foreign_loader
257
+ return loader_expected_cpath(file_path, loader) if owner == :loader
258
+ return foreign_loader_constant_path(file_path, root, foreign_root, loader) if owner == :foreign_loader
241
259
 
242
260
  local_managed_constant_path(file_path, root)
243
261
  end
244
262
 
245
- # The constant path the active main loader expects for +file_path+,
263
+ # The constant path the owning loader expects for +file_path+,
246
264
  # asked through the loader's own API so its inflector, namespace
247
265
  # collapse, and ignore rules decide (a custom `api => API` inflection
248
266
  # must win over the local camelizer). Available on Zeitwerk 2.6.9+;
@@ -251,9 +269,9 @@ module Woods
251
269
  # local camelizer must not rescue it.
252
270
  #
253
271
  # @param file_path [String] Expanded absolute path to the file
272
+ # @param loader [Zeitwerk::Loader] Owning loader
254
273
  # @return [String, nil]
255
- def loader_expected_cpath(file_path)
256
- loader = main_loader
274
+ def loader_expected_cpath(file_path, loader)
257
275
  return nil unless loader.respond_to?(:cpath_expected_at)
258
276
 
259
277
  loader.cpath_expected_at(file_path)
@@ -281,19 +299,20 @@ module Woods
281
299
  # @param file_path [String] Expanded absolute path to the file
282
300
  # @param root [String] Active managed root the path is relative to
283
301
  # @param foreign_root [String, nil] Matching managed root from loader.dirs
302
+ # @param loader [Zeitwerk::Loader] Owning loader for the foreign root
284
303
  # @return [String, nil]
285
- def foreign_loader_constant_path(file_path, root, foreign_root)
304
+ def foreign_loader_constant_path(file_path, root, foreign_root, loader)
286
305
  mapped = foreign_root && mapped_foreign_path(file_path, root, foreign_root)
287
306
  return nil unless mapped
288
307
 
289
308
  # If the corresponding boot-root path exists, the real loader can
290
309
  # answer with full semantics: ignores, root namespaces, collapses,
291
310
  # and custom inflections. A nil answer is an authoritative non-claim.
292
- return loader_expected_cpath(mapped) if File.exist?(mapped)
311
+ return loader_expected_cpath(mapped, loader) if File.exist?(mapped)
293
312
 
294
- return nil unless copied_only_loader_fallback_allowed?(file_path, root, foreign_root)
313
+ return nil unless copied_only_loader_fallback_allowed?(file_path, root, foreign_root, loader)
295
314
 
296
- inflected_managed_constant_path(file_path, root)
315
+ inflected_managed_constant_path(file_path, root, loader)
297
316
  end
298
317
 
299
318
  # @param file_path [String] Expanded absolute path under the active root
@@ -314,22 +333,23 @@ module Woods
314
333
  #
315
334
  # @param file_path [String] Expanded absolute path to the file
316
335
  # @param root [String] The managed root the path is relative to
336
+ # @param loader [Zeitwerk::Loader] Owning loader for the corresponding foreign root
317
337
  # @return [String]
318
- def inflected_managed_constant_path(file_path, root)
338
+ def inflected_managed_constant_path(file_path, root, loader)
319
339
  relative = file_path[root.length..].to_s.sub(/\.rb\z/, '')
320
340
  segments = relative.split('/').reject(&:empty?)
321
341
  current = root.chomp('/')
322
342
  segments.map do |segment|
323
343
  current = File.join(current, segment)
324
- loader_camelize(segment, current)
344
+ loader_camelize(segment, current, loader)
325
345
  end.join('::')
326
346
  end
327
347
 
328
348
  # @param segment [String] Path segment without extension
329
349
  # @param abspath [String] Absolute path passed to Zeitwerk inflectors
350
+ # @param loader [Zeitwerk::Loader] Owning loader
330
351
  # @return [String]
331
- def loader_camelize(segment, abspath)
332
- loader = main_loader
352
+ def loader_camelize(segment, abspath, loader)
333
353
  inflector = loader.respond_to?(:inflector) ? loader.inflector : nil
334
354
  return camelize_segment(segment) unless inflector.respond_to?(:camelize)
335
355
 
@@ -345,20 +365,27 @@ module Woods
345
365
  # @param file_path [String] Expanded absolute path to the file
346
366
  # @param root [String] Active managed root the path is relative to
347
367
  # @param foreign_root [String] Matching boot-loader managed root
368
+ # @param loader [Zeitwerk::Loader] Owning loader for the foreign root
348
369
  # @return [Boolean]
349
- def copied_only_loader_fallback_allowed?(file_path, root, foreign_root)
350
- loader_root_namespace(foreign_root) == Object &&
351
- !mapped_path_uses_collapsed_directory?(file_path, root, foreign_root) &&
352
- !mapped_path_crosses_loader_root?(file_path, root, foreign_root)
370
+ def copied_only_loader_fallback_allowed?(file_path, root, foreign_root, loader)
371
+ loader_root_namespace(foreign_root, loader) == Object &&
372
+ !mapped_path_uses_collapsed_directory?(file_path, root, foreign_root, loader) &&
373
+ !mapped_path_crosses_loader_root?(file_path, root, foreign_root, loader)
353
374
  end
354
375
 
355
376
  # A nested loader root resets Zeitwerk's constant-path origin. The
356
377
  # copied-only fallback cannot ask +cpath_expected_at+ about a file that
357
378
  # exists only in the active copy, so it must not camelize the nested
358
379
  # root directory as an extra namespace segment.
359
- def mapped_path_crosses_loader_root?(file_path, root, foreign_root)
380
+ #
381
+ # @param file_path [String] Expanded absolute path to the file
382
+ # @param root [String] Active managed root
383
+ # @param foreign_root [String] Matching boot-loader managed root
384
+ # @param loader [Zeitwerk::Loader] Owning loader for the foreign root
385
+ # @return [Boolean]
386
+ def mapped_path_crosses_loader_root?(file_path, root, foreign_root, loader)
360
387
  mapped = mapped_foreign_path(file_path, root, foreign_root)
361
- Array(main_loader.dirs).any? do |dir|
388
+ Array(loader.dirs).any? do |dir|
362
389
  nested = File.expand_path(dir.to_s.chomp('/'))
363
390
  nested != foreign_root && mapped.start_with?("#{nested}/")
364
391
  end
@@ -367,9 +394,9 @@ module Woods
367
394
  end
368
395
 
369
396
  # @param foreign_root [String] Matching boot-loader managed root
397
+ # @param loader [Zeitwerk::Loader] Owning loader for the foreign root
370
398
  # @return [Module]
371
- def loader_root_namespace(foreign_root)
372
- loader = main_loader
399
+ def loader_root_namespace(foreign_root, loader)
373
400
  namespaced_dirs = loader.dirs(namespaces: true) if loader.respond_to?(:dirs)
374
401
  return Object unless namespaced_dirs.respond_to?(:[])
375
402
 
@@ -381,9 +408,9 @@ module Woods
381
408
  # @param file_path [String] Expanded absolute path to the file
382
409
  # @param root [String] Active managed root
383
410
  # @param foreign_root [String] Matching boot-loader managed root
411
+ # @param loader [Zeitwerk::Loader] Owning loader for the foreign root
384
412
  # @return [Boolean]
385
- def mapped_path_uses_collapsed_directory?(file_path, root, foreign_root)
386
- loader = main_loader
413
+ def mapped_path_uses_collapsed_directory?(file_path, root, foreign_root, loader)
387
414
  return false unless loader.respond_to?(:__collapse?)
388
415
 
389
416
  relative = file_path[root.length..].to_s.sub(%r{\A/}, '').sub(/\.rb\z/, '')
@@ -401,11 +428,12 @@ module Woods
401
428
  # The managed root directory containing +file_path+, if any, with the
402
429
  # authority that vouches for it.
403
430
  #
404
- # When a Rails autoloader is up, its directory list is the authority
405
- # for the files it claims. For a file it does NOT claim, the
406
- # non-claim stays authoritative whenever the loader demonstrably
407
- # belongs to the active root (any loader directory under it) or
408
- # reports an empty set (classic mode): unmanaged. Only when every
431
+ # Both Rails autoloaders' directory lists are checked for direct
432
+ # ownership before any copied-root inference. The most specific root
433
+ # wins; equally specific roots owned by different loaders are ambiguous.
434
+ # For a file neither claims, the non-claim stays authoritative whenever
435
+ # either loader has a directory in the active tree, or both report an
436
+ # empty set (classic mode). Only when every
409
437
  # loader root belongs to a DIFFERENT tree — copied-app and
410
438
  # multi-worktree extractions, where the loader belongs to the boot
411
439
  # root while Rails.root is repointed per slot — does the root-relative
@@ -415,28 +443,37 @@ module Woods
415
443
  # {MANAGED_PATH_PATTERN} stands in offline.
416
444
  #
417
445
  # @param file_path [String] Absolute path to the source file
418
- # @return [Array(String, Symbol), nil] `[root, owner]` with owner
419
- # +:loader+, +:foreign_loader+, or +:convention+, or nil when the
420
- # file is unmanaged
446
+ # @return [Array, nil] `[root, authority, loader, foreign_root]`, with
447
+ # authority +:loader+, +:foreign_loader+, or +:convention+; nil when
448
+ # unmanaged. The last two fields are omitted for the offline convention.
421
449
  def managed_root_for(file_path)
422
450
  file_path = File.expand_path(file_path)
423
- loader = main_loader
424
- unless loader
451
+ loaders = rails_loaders
452
+ if loaders.empty?
425
453
  root = MANAGED_PATH_PATTERN.match(file_path)&.captures&.first
426
454
  return root ? [root, :convention] : nil
427
455
  end
428
456
 
429
- dirs = Array(loader.dirs).map { |dir| File.expand_path(dir.to_s.chomp('/')) }
457
+ roots = loaders.flat_map do |loader|
458
+ Array(loader.dirs).map { |dir| [File.expand_path(dir.to_s.chomp('/')), loader] }
459
+ end.uniq
460
+
461
+ claimed = roots.select { |dir, _loader| file_path.start_with?("#{dir}/") }
462
+ unless claimed.empty?
463
+ deepest = claimed.map { |dir, _loader| dir.length }.max
464
+ owners = claimed.select { |dir, _loader| dir.length == deepest }
465
+ return nil unless owners.one?
430
466
 
431
- claimed = dirs.find { |dir| file_path.start_with?("#{dir}/") }
432
- return [claimed, :loader] if claimed
467
+ dir, loader = owners.first
468
+ return [dir, :loader, loader]
469
+ end
433
470
 
434
471
  active = active_root
435
472
  return nil unless active
436
- return nil if dirs.any? { |dir| dir.start_with?("#{active}/") }
437
- return nil if dirs.empty?
473
+ return nil if roots.any? { |dir, _loader| dir == active || dir.start_with?("#{active}/") }
474
+ return nil if roots.empty?
438
475
 
439
- active_root_managed_for(file_path, active, dirs)
476
+ active_root_managed_for(file_path, active, roots)
440
477
  end
441
478
 
442
479
  # Root-relative managed root for a file the autoloader does not
@@ -450,10 +487,9 @@ module Woods
450
487
  #
451
488
  # @param file_path [String] Absolute path to the source file
452
489
  # @param active [String] Expanded active extraction root
453
- # @param dirs [Array<String>] Foreign loader roots
454
- # @return [Array(String, Symbol, String), nil] `[root, :foreign_loader,
455
- # foreign_root]`, or nil
456
- def active_root_managed_for(file_path, active, dirs)
490
+ # @param roots [Array<Array>] Foreign `[directory, loader]` pairs
491
+ # @return [Array, nil] `[root, :foreign_loader, loader, foreign_root]`, or nil
492
+ def active_root_managed_for(file_path, active, roots)
457
493
  return nil unless file_path.start_with?("#{active}/")
458
494
 
459
495
  shape = file_path[active.length..].to_s.match(%r{\A(/app/[^/]+/)})
@@ -461,20 +497,18 @@ module Woods
461
497
 
462
498
  root = "#{active}#{shape[1]}"
463
499
  suffix = shape[1].chomp('/')
464
- candidates = dirs.select { |dir| dir.end_with?(suffix) }
500
+ candidates = roots.select { |dir, _loader| dir.end_with?(suffix) }
465
501
  return nil if candidates.empty?
466
502
 
467
- existing = candidates.select do |foreign_root|
503
+ existing = candidates.select do |foreign_root, _loader|
468
504
  File.exist?(mapped_foreign_path(file_path, root, foreign_root))
469
505
  end
470
506
 
471
- return [root, :foreign_loader, existing.first] if existing.one?
472
- return nil if existing.any?
473
-
474
- foreign_root = candidates.one? ? candidates.first : nil
475
- return nil unless foreign_root
507
+ candidates = existing unless existing.empty?
508
+ return nil unless candidates.one?
476
509
 
477
- [root, :foreign_loader, foreign_root]
510
+ foreign_root, loader = candidates.first
511
+ [root, :foreign_loader, loader, foreign_root]
478
512
  end
479
513
 
480
514
  # The active extraction root, expanded: the boot root in a normal
@@ -490,21 +524,23 @@ module Woods
490
524
  nil
491
525
  end
492
526
 
493
- # The main Rails autoloader, or nil when there is none to consult
527
+ # The available Rails autoloaders, or an empty array when none can be consulted
494
528
  # (no Rails, a spec stub). In classic mode the loader exists with an
495
529
  # empty directory list — an authoritative empty set.
496
530
  #
497
- # @return [Zeitwerk::Loader, nil]
498
- def main_loader
499
- return nil unless defined?(Rails) && Rails.respond_to?(:autoloaders)
531
+ # @return [Array<Zeitwerk::Loader>]
532
+ def rails_loaders
533
+ return [] unless defined?(Rails) && Rails.respond_to?(:autoloaders)
500
534
 
501
535
  autoloaders = Rails.autoloaders
502
- return nil unless autoloaders.respond_to?(:main)
536
+ %i[main once].filter_map do |name|
537
+ next unless autoloaders.respond_to?(name)
503
538
 
504
- main = autoloaders.main
505
- main.respond_to?(:dirs) ? main : nil
539
+ loader = autoloaders.public_send(name)
540
+ loader if loader.respond_to?(:dirs)
541
+ end.uniq
506
542
  rescue StandardError
507
- nil
543
+ []
508
544
  end
509
545
 
510
546
  # Camelize one path segment the way Zeitwerk's default inflector