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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +94 -7
- data/CONTRIBUTING.md +134 -19
- data/README.md +1 -1
- data/docs/AGENT_GUIDE.md +19 -0
- data/docs/AGENT_SETUP.md +22 -2
- data/docs/BACKEND_MATRIX.md +7 -0
- data/docs/CLIENT_HOOKS.md +6 -0
- data/docs/CONFIGURATION_REFERENCE.md +133 -25
- data/docs/CONSOLE_MCP_SETUP.md +82 -30
- data/docs/EMBEDDING_MODELS.md +16 -19
- data/docs/EXTRACTOR_REFERENCE.md +219 -21
- data/docs/FAQ.md +11 -25
- data/docs/GETTING_STARTED.md +7 -1
- data/docs/INCREMENTAL_EXTRACTION.md +261 -19
- data/docs/INDEX_LAYOUT.md +5 -0
- data/docs/INTERNALS.md +9 -0
- data/docs/MCP_HTTP_TRANSPORT.md +20 -15
- data/docs/MCP_SERVERS.md +87 -8
- data/docs/MCP_TOOL_COOKBOOK.md +13 -55
- data/docs/NOTION_INTEGRATION.md +7 -1
- data/docs/PUBLISHED_INDEX.md +6 -0
- data/docs/README.md +6 -1
- data/docs/RETRIEVAL_GUIDE.md +17 -0
- data/docs/SOURCE_FRESHNESS.md +157 -5
- data/docs/TOKEN_BENCHMARK.md +10 -18
- data/docs/TROUBLESHOOTING.md +70 -14
- data/docs/UNBLOCKED_INTEGRATION.md +60 -8
- data/docs/UPGRADING_TO_2.md +153 -38
- data/docs/WATCH_DAEMON.md +97 -14
- data/exe/woods-console-mcp +2 -2
- data/lib/generators/woods/templates/woods.rb.tt +2 -1
- data/lib/tasks/woods.rake +23 -7
- data/lib/tasks/woods_checks.rake +2 -2
- data/lib/woods/agent_configuration/cli.rb +1 -1
- data/lib/woods/agent_configuration/layout.rb +16 -2
- data/lib/woods/agent_configuration/plan.rb +13 -3
- data/lib/woods/agent_configuration/planner_validation.rb +4 -2
- data/lib/woods/agent_configuration/preflight.rb +5 -3
- data/lib/woods/builder.rb +17 -57
- data/lib/woods/cache/cache_middleware.rb +56 -30
- data/lib/woods/chunking/contributor_chunks.rb +119 -0
- data/lib/woods/chunking/semantic_chunker.rb +44 -21
- data/lib/woods/console/connection_manager.rb +56 -3
- data/lib/woods/console/embedded_executor.rb +30 -5
- data/lib/woods/console/rack_middleware.rb +29 -1
- data/lib/woods/dependency_graph.rb +34 -10
- data/lib/woods/embedding/fake.rb +12 -0
- data/lib/woods/embedding/indexer.rb +195 -98
- data/lib/woods/embedding/input_budget.rb +67 -0
- data/lib/woods/embedding/openai.rb +70 -20
- data/lib/woods/embedding/provider.rb +37 -25
- data/lib/woods/embedding/text_preparer.rb +76 -32
- data/lib/woods/embedding/token_counter.rb +18 -81
- data/lib/woods/embedding/vector_configuration.rb +48 -0
- data/lib/woods/extraction_identities.rb +175 -0
- data/lib/woods/extractor.rb +304 -107
- data/lib/woods/extractors/action_cable_extractor.rb +8 -3
- data/lib/woods/extractors/assigned_value_discovery.rb +74 -0
- data/lib/woods/extractors/class_declarations.rb +121 -0
- data/lib/woods/extractors/configuration_extractor.rb +11 -3
- data/lib/woods/extractors/declaration_ancestry.rb +92 -0
- data/lib/woods/extractors/event_extractor.rb +8 -0
- data/lib/woods/extractors/graphql_extractor.rb +134 -77
- data/lib/woods/extractors/job_extractor.rb +5 -1
- data/lib/woods/extractors/lib_extractor.rb +132 -15
- data/lib/woods/extractors/mailer_extractor.rb +3 -5
- data/lib/woods/extractors/manager_extractor.rb +7 -21
- data/lib/woods/extractors/migration_declaration.rb +87 -0
- data/lib/woods/extractors/migration_extractor.rb +5 -39
- data/lib/woods/extractors/phlex_extractor.rb +6 -2
- data/lib/woods/extractors/policy_extractor.rb +9 -5
- data/lib/woods/extractors/poro_extractor.rb +112 -53
- data/lib/woods/extractors/pundit_extractor.rb +11 -6
- data/lib/woods/extractors/scheduled_job_extractor.rb +45 -4
- data/lib/woods/extractors/serializer_extractor.rb +34 -22
- data/lib/woods/extractors/shared_utility_methods.rb +18 -1
- data/lib/woods/extractors/source_nesting.rb +142 -106
- data/lib/woods/extractors/standalone_module_discovery.rb +123 -0
- data/lib/woods/extractors/state_machine_extractor.rb +46 -40
- data/lib/woods/extractors/view_component_extractor.rb +9 -7
- data/lib/woods/flow_assembler.rb +4 -1
- data/lib/woods/generation.rb +25 -0
- data/lib/woods/hooks/context_hint.rb +7 -2
- data/lib/woods/mcp/bootstrapper.rb +33 -7
- data/lib/woods/mcp/config_resolver.rb +26 -7
- data/lib/woods/mcp/index_reader.rb +125 -24
- data/lib/woods/mcp/index_reader_pinning.rb +16 -0
- data/lib/woods/mcp/renderers/markdown_renderer.rb +7 -1
- data/lib/woods/mcp/renderers/plain_renderer.rb +3 -1
- data/lib/woods/mcp/search_results.rb +7 -1
- data/lib/woods/mcp/server.rb +24 -4
- data/lib/woods/module_reconciliation.rb +151 -0
- data/lib/woods/path_dispatcher.rb +7 -2
- data/lib/woods/rake_helpers.rb +43 -11
- data/lib/woods/release.rb +1 -1
- data/lib/woods/resilience/index_validator.rb +8 -3
- data/lib/woods/resilience/retryable_provider.rb +18 -1
- data/lib/woods/resolved_config.rb +68 -8
- data/lib/woods/retrieval/context_assembler.rb +3 -3
- data/lib/woods/retrieval/lexical_assembler.rb +3 -2
- data/lib/woods/retrieval/scope.rb +18 -2
- data/lib/woods/retrieval/source_evidence.rb +14 -2
- data/lib/woods/source_contributor_validation.rb +78 -0
- data/lib/woods/source_contributors.rb +116 -0
- data/lib/woods/source_inputs/handoff.rb +37 -0
- data/lib/woods/source_inputs/launcher.rb +53 -13
- data/lib/woods/source_inputs/manifest.rb +84 -3
- data/lib/woods/source_inputs/private_key.rb +44 -12
- data/lib/woods/source_inputs/scanner.rb +98 -27
- data/lib/woods/source_inputs/scopes.rb +1 -1
- data/lib/woods/source_inputs/session.rb +147 -15
- data/lib/woods/source_inputs/stable_reader.rb +127 -0
- data/lib/woods/source_inputs/status.rb +40 -8
- data/lib/woods/source_inputs/verifier.rb +28 -5
- data/lib/woods/source_path_encoding.rb +33 -0
- data/lib/woods/source_references/cache.rb +284 -0
- data/lib/woods/source_references/collector.rb +120 -0
- data/lib/woods/source_references/extraction.rb +185 -0
- data/lib/woods/source_references/inputs.rb +134 -0
- data/lib/woods/source_references/parser_adapter.rb +134 -0
- data/lib/woods/source_references/pass.rb +152 -0
- data/lib/woods/source_references/prism_adapter.rb +116 -0
- data/lib/woods/source_references/registry.rb +178 -0
- data/lib/woods/source_references/runtime_lookup.rb +127 -0
- data/lib/woods/source_references/value_class.rb +82 -0
- data/lib/woods/storage/metadata_store.rb +4 -1
- data/lib/woods/storage/qdrant.rb +2 -2
- data/lib/woods/unblocked/client.rb +12 -7
- data/lib/woods/unblocked/document_builder.rb +4 -1
- data/lib/woods/unblocked/exporter.rb +127 -37
- data/lib/woods/unblocked/sync_manifest.rb +137 -21
- data/lib/woods/unblocked/uri_migration.rb +105 -0
- data/lib/woods/util/host_guard.rb +3 -2
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/catch_up.rb +138 -0
- data/lib/woods/watch/claim_lease.rb +150 -0
- data/lib/woods/watch/cli.rb +26 -2
- data/lib/woods/watch/daemon.rb +80 -59
- data/lib/woods/watch/installation/options.rb +1 -1
- data/lib/woods/watch/installation/receipt.rb +6 -1
- data/lib/woods/watch/managed_child.rb +1 -1
- data/lib/woods/watch/supervisor.rb +1 -1
- data/lib/woods/watch/tree_scan.rb +14 -2
- data/plugin/.claude-plugin/plugin.json +1 -1
- data/plugin/hooks/adapters/normalize.rb +3 -2
- data/plugin/hooks/woods-input-rules.sh +4 -0
- data/plugin/hooks/woods-refresh.sh +15 -7
- data/plugin/hooks/woods-session-start.sh +60 -3
- data/plugin/skills/woods-diagnose/SKILL.md +334 -11
- data/plugin/skills/woods-investigate/SKILL.md +11 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +79 -8
- data/plugin/skills/woods-setup/SKILL.md +53 -6
- 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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
19
|
-
# a
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
#
|
|
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
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
#
|
|
128
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
|
189
|
-
#
|
|
190
|
-
#
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
#
|
|
405
|
-
#
|
|
406
|
-
#
|
|
407
|
-
#
|
|
408
|
-
#
|
|
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
|
|
419
|
-
# +:loader+, +:foreign_loader+, or +:convention
|
|
420
|
-
#
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|
437
|
-
return nil if
|
|
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,
|
|
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
|
|
454
|
-
# @return [Array
|
|
455
|
-
|
|
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 =
|
|
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
|
-
|
|
472
|
-
return nil
|
|
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
|
-
|
|
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
|
|
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
|
|
498
|
-
def
|
|
499
|
-
return
|
|
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
|
-
|
|
536
|
+
%i[main once].filter_map do |name|
|
|
537
|
+
next unless autoloaders.respond_to?(name)
|
|
503
538
|
|
|
504
|
-
|
|
505
|
-
|
|
539
|
+
loader = autoloaders.public_send(name)
|
|
540
|
+
loader if loader.respond_to?(:dirs)
|
|
541
|
+
end.uniq
|
|
506
542
|
rescue StandardError
|
|
507
|
-
|
|
543
|
+
[]
|
|
508
544
|
end
|
|
509
545
|
|
|
510
546
|
# Camelize one path segment the way Zeitwerk's default inflector
|