woods 1.6.1 → 2.0.0.beta2
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 +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- data/lib/woods/observability/instrumentation.rb +0 -34
|
@@ -34,12 +34,17 @@ module Woods
|
|
|
34
34
|
#
|
|
35
35
|
# @return [Array<ExtractedUnit>] List of channel units
|
|
36
36
|
def extract_all
|
|
37
|
-
|
|
37
|
+
discoverable_classes.filter_map { |klass| extract_channel(klass) }
|
|
38
|
+
end
|
|
38
39
|
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
# The channel classes this extractor would extract from the running app.
|
|
41
|
+
# Shared with the incremental path's class reconciliation (#164).
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<Class>]
|
|
44
|
+
def discoverable_classes
|
|
45
|
+
return [] unless action_cable_available?
|
|
41
46
|
|
|
42
|
-
|
|
47
|
+
channel_descendants
|
|
43
48
|
end
|
|
44
49
|
|
|
45
50
|
# Extract a single channel class into an ExtractedUnit.
|
|
@@ -35,12 +35,30 @@ module Woods
|
|
|
35
35
|
file, _line = source_location
|
|
36
36
|
return nil unless File.exist?(file)
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
Ast::MethodExtractor.new.extract_method_source(source, action.to_s)
|
|
38
|
+
action_sources_for(file)[action.to_s]
|
|
40
39
|
rescue StandardError => e
|
|
41
40
|
Rails.logger.debug("Could not extract action source for #{klass}##{action}: #{e.message}")
|
|
42
41
|
nil
|
|
43
42
|
end
|
|
43
|
+
|
|
44
|
+
# Every instance-method source in one file, keyed by method name — read
|
|
45
|
+
# and parsed once per file per extractor instance (audit P1).
|
|
46
|
+
#
|
|
47
|
+
# {#build_action_chunks} in the including extractors calls
|
|
48
|
+
# {#extract_action_source} once per action, so a 30-action controller
|
|
49
|
+
# previously read and re-parsed its own source 30 times. The parse
|
|
50
|
+
# result is a pure function of the file's bytes, so answering every
|
|
51
|
+
# action from one parse is byte-identical to parsing per action.
|
|
52
|
+
#
|
|
53
|
+
# A file whose source fails to parse stays uncached (the `||=` never
|
|
54
|
+
# assigns), so each action retries it and rescues exactly as before.
|
|
55
|
+
#
|
|
56
|
+
# @param file [String] Absolute path of the defining file
|
|
57
|
+
# @return [Hash{String => String, nil}] method name => source text
|
|
58
|
+
def action_sources_for(file)
|
|
59
|
+
(@action_sources ||= {})[file] ||=
|
|
60
|
+
Ast::MethodExtractor.new.extract_method_sources(File.read(file))
|
|
61
|
+
end
|
|
44
62
|
end
|
|
45
63
|
end
|
|
46
64
|
end
|
|
@@ -159,7 +159,12 @@ module Woods
|
|
|
159
159
|
|
|
160
160
|
# Extract individual cache call entries from source.
|
|
161
161
|
#
|
|
162
|
-
# Each entry has :type, :key_pattern, and :ttl
|
|
162
|
+
# Each entry has :type, :key_pattern, and :ttl, parsed from that
|
|
163
|
+
# occurrence's own argument text (the remainder of its line) — not
|
|
164
|
+
# from the whole file. Matching against the whole source gave every
|
|
165
|
+
# entry the first call's key and the first `expires_in:` in the file
|
|
166
|
+
# (#201); a call without its own `expires_in:` gets a nil ttl rather
|
|
167
|
+
# than inheriting a neighbor's.
|
|
163
168
|
#
|
|
164
169
|
# @param source [String] Source code
|
|
165
170
|
# @return [Array<Hash>] Cache call descriptors
|
|
@@ -167,36 +172,65 @@ module Woods
|
|
|
167
172
|
calls = []
|
|
168
173
|
|
|
169
174
|
CACHE_PATTERNS.each do |type, pattern|
|
|
170
|
-
source
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
175
|
+
each_occurrence(source, pattern) do |offset|
|
|
176
|
+
call_text = call_argument_text(source, offset)
|
|
177
|
+
calls << {
|
|
178
|
+
type: type,
|
|
179
|
+
key_pattern: extract_key_pattern(call_text, type),
|
|
180
|
+
ttl: extract_ttl(call_text)
|
|
181
|
+
}
|
|
174
182
|
end
|
|
175
183
|
end
|
|
176
184
|
|
|
177
185
|
calls
|
|
178
186
|
end
|
|
179
187
|
|
|
188
|
+
# Yield the start offset of every non-overlapping occurrence of a pattern.
|
|
189
|
+
#
|
|
190
|
+
# @param source [String] Source code
|
|
191
|
+
# @param pattern [Regexp] Pattern to scan for
|
|
192
|
+
# @yieldparam offset [Integer] Character offset where the occurrence begins
|
|
193
|
+
# @return [void]
|
|
194
|
+
def each_occurrence(source, pattern)
|
|
195
|
+
offset = 0
|
|
196
|
+
while (match = source.match(pattern, offset))
|
|
197
|
+
yield match.begin(0)
|
|
198
|
+
offset = [match.end(0), match.begin(0) + 1].max
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The argument text belonging to one cache call occurrence: the
|
|
203
|
+
# remainder of the line the call starts on. Bounding the slice to the
|
|
204
|
+
# line keeps key/TTL parsing from reading a neighboring call's options.
|
|
205
|
+
#
|
|
206
|
+
# @param source [String] Source code
|
|
207
|
+
# @param offset [Integer] Character offset where the occurrence begins
|
|
208
|
+
# @return [String] The call's own argument text
|
|
209
|
+
def call_argument_text(source, offset)
|
|
210
|
+
line_end = source.index("\n", offset) || source.length
|
|
211
|
+
source[offset...line_end]
|
|
212
|
+
end
|
|
213
|
+
|
|
180
214
|
# Extract the key pattern for a Rails.cache call.
|
|
181
215
|
#
|
|
182
216
|
# Returns a simplified string representation of the first argument.
|
|
183
217
|
#
|
|
184
|
-
# @param
|
|
218
|
+
# @param call_text [String] The call's own argument text
|
|
185
219
|
# @param type [Symbol] The cache call type
|
|
186
220
|
# @return [String, nil] The key pattern or nil
|
|
187
|
-
def extract_key_pattern(
|
|
221
|
+
def extract_key_pattern(call_text, type)
|
|
188
222
|
return nil unless %i[fetch read write delete exist].include?(type)
|
|
189
223
|
|
|
190
|
-
match =
|
|
224
|
+
match = call_text.match(KEY_PATTERN)
|
|
191
225
|
match ? match[1].strip[0, 60] : nil
|
|
192
226
|
end
|
|
193
227
|
|
|
194
|
-
# Extract TTL value from expires_in option.
|
|
228
|
+
# Extract TTL value from the call's own expires_in option.
|
|
195
229
|
#
|
|
196
|
-
# @param
|
|
230
|
+
# @param call_text [String] The call's own argument text
|
|
197
231
|
# @return [String, nil] The TTL expression or nil
|
|
198
|
-
def extract_ttl(
|
|
199
|
-
match =
|
|
232
|
+
def extract_ttl(call_text)
|
|
233
|
+
match = call_text.match(TTL_PATTERN)
|
|
200
234
|
match ? match[1].strip : nil
|
|
201
235
|
end
|
|
202
236
|
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'set'
|
|
4
4
|
require_relative '../ast/parser'
|
|
5
5
|
require_relative '../flow_analysis/operation_extractor'
|
|
6
|
+
require_relative 'line_neutralizer'
|
|
7
|
+
require_relative 'reference_patterns'
|
|
6
8
|
|
|
7
9
|
module Woods
|
|
8
10
|
module Extractors
|
|
@@ -32,7 +34,15 @@ module Woods
|
|
|
32
34
|
MULTI_COLUMN_WRITERS = %w[update_columns assign_attributes].freeze
|
|
33
35
|
|
|
34
36
|
# Async enqueue methods that indicate a job is being dispatched.
|
|
35
|
-
ASYNC_METHODS =
|
|
37
|
+
ASYNC_METHODS = ReferencePatterns::ENQUEUE_METHODS
|
|
38
|
+
|
|
39
|
+
# Assignment to a column through +self+: plain, conditional (`||=`,
|
|
40
|
+
# `&&=`), or arithmetic (`+=`, `-=`, `*=`, `/=`). `self.token ||=
|
|
41
|
+
# SecureRandom.uuid` in a before_create is arguably the most common
|
|
42
|
+
# callback body in Rails and reported no write at all (EXTA-6). The
|
|
43
|
+
# lookahead rejects `==` (comparison) and `=~` (regex match), the
|
|
44
|
+
# latter having been read as a write.
|
|
45
|
+
SELF_ASSIGNMENT_PATTERN = %r{self\.(\w+)\s*(?:\|\||&&|[+\-*/])?=(?!=|~)}
|
|
36
46
|
|
|
37
47
|
# Pre-compiled regex patterns (avoid dynamic regex construction in hot loops)
|
|
38
48
|
SINGLE_COLUMN_WRITER_PATTERNS = SINGLE_COLUMN_WRITERS.to_h do |w|
|
|
@@ -43,7 +53,7 @@ module Woods
|
|
|
43
53
|
[w, /\b#{Regexp.escape(w)}\s*\(([^)]+)\)/m]
|
|
44
54
|
end.freeze
|
|
45
55
|
|
|
46
|
-
ASYNC_PATTERN =
|
|
56
|
+
ASYNC_PATTERN = ReferencePatterns::JOB_ENQUEUE
|
|
47
57
|
|
|
48
58
|
DB_READ_PATTERNS = DB_READ_METHODS.to_h do |m|
|
|
49
59
|
[m, /\.#{Regexp.escape(m)}\b/]
|
|
@@ -74,7 +84,7 @@ module Woods
|
|
|
74
84
|
|
|
75
85
|
return callback_hash.merge(side_effects: empty_side_effects) if method_node.nil?
|
|
76
86
|
|
|
77
|
-
method_source = method_source_from_node(method_node)
|
|
87
|
+
method_source = scannable_source(method_source_from_node(method_node))
|
|
78
88
|
return callback_hash.merge(side_effects: empty_side_effects) if method_source.nil?
|
|
79
89
|
|
|
80
90
|
callback_hash.merge(
|
|
@@ -130,6 +140,24 @@ module Woods
|
|
|
130
140
|
lines[start_idx..end_idx].join
|
|
131
141
|
end
|
|
132
142
|
|
|
143
|
+
# Neutralize a method body before the regex scans run over it.
|
|
144
|
+
#
|
|
145
|
+
# The body is located by AST but scanned by regex, so
|
|
146
|
+
# `logger.info "self.status = pending"` and a commented-out
|
|
147
|
+
# `# self.status = 'x'` both reported a column write (EXTA-8).
|
|
148
|
+
# {LineNeutralizer} blanks comment and string-literal bodies, which is
|
|
149
|
+
# exactly the text a side-effect scan must not believe; identifiers,
|
|
150
|
+
# call chains, and symbols — everything the patterns actually match —
|
|
151
|
+
# survive untouched.
|
|
152
|
+
#
|
|
153
|
+
# @param method_source [String, nil]
|
|
154
|
+
# @return [String, nil]
|
|
155
|
+
def scannable_source(method_source)
|
|
156
|
+
return nil if method_source.nil?
|
|
157
|
+
|
|
158
|
+
LineNeutralizer.neutralize_lines(method_source).join
|
|
159
|
+
end
|
|
160
|
+
|
|
133
161
|
# Check if a filter string looks like a valid Ruby method name.
|
|
134
162
|
# Rejects proc/lambda string representations and other non-method filters.
|
|
135
163
|
#
|
|
@@ -150,8 +178,8 @@ module Woods
|
|
|
150
178
|
def detect_columns_written(method_source)
|
|
151
179
|
columns = Set.new
|
|
152
180
|
|
|
153
|
-
# Pattern: self.col = value (
|
|
154
|
-
method_source.scan(
|
|
181
|
+
# Pattern: self.col = value (plain, ||=, &&=, +=, … — never == or =~)
|
|
182
|
+
method_source.scan(SELF_ASSIGNMENT_PATTERN).flatten.each do |col|
|
|
155
183
|
columns << col if @column_names.include?(col)
|
|
156
184
|
end
|
|
157
185
|
|
|
@@ -186,22 +214,24 @@ module Woods
|
|
|
186
214
|
|
|
187
215
|
# Detect service objects called by the callback method.
|
|
188
216
|
#
|
|
189
|
-
# Matches classes ending in Service followed by a method call
|
|
217
|
+
# Matches classes ending in Service followed by a method call,
|
|
218
|
+
# namespace included (EXTA-2).
|
|
190
219
|
#
|
|
191
220
|
# @param method_source [String]
|
|
192
221
|
# @return [Array<String>]
|
|
193
222
|
def detect_services_called(method_source)
|
|
194
|
-
method_source.scan(
|
|
223
|
+
method_source.scan(ReferencePatterns::SERVICE_REFERENCE).flatten.uniq.sort
|
|
195
224
|
end
|
|
196
225
|
|
|
197
226
|
# Detect mailers triggered by the callback method.
|
|
198
227
|
#
|
|
199
|
-
# Matches classes ending in Mailer followed by a method call
|
|
228
|
+
# Matches classes ending in Mailer followed by a method call,
|
|
229
|
+
# namespace included (EXTA-2).
|
|
200
230
|
#
|
|
201
231
|
# @param method_source [String]
|
|
202
232
|
# @return [Array<String>]
|
|
203
233
|
def detect_mailers_triggered(method_source)
|
|
204
|
-
method_source.scan(
|
|
234
|
+
method_source.scan(ReferencePatterns::MAILER_REFERENCE).flatten.uniq.sort
|
|
205
235
|
end
|
|
206
236
|
|
|
207
237
|
# Detect database read operations in the callback method.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Extractors
|
|
5
|
+
# Finds component classes the eager load never reached.
|
|
6
|
+
#
|
|
7
|
+
# Both component extractors discover their units through
|
|
8
|
+
# `component_base.descendants`, which only knows the classes something has
|
|
9
|
+
# already loaded. Rails leaves `app/views` out of both `autoload_paths` and
|
|
10
|
+
# `eager_load_paths`, so an app that keeps components beside their
|
|
11
|
+
# templates and opts the subtree into autoloading gets classes that exist
|
|
12
|
+
# on disk, resolve by name, and are absent from `descendants` for the whole
|
|
13
|
+
# extraction (B-184).
|
|
14
|
+
#
|
|
15
|
+
# This walks the configured component directories and asks the autoloader
|
|
16
|
+
# for each file's constant before `descendants` is read. Zeitwerk does the
|
|
17
|
+
# loading, so a file that does not define the constant its path implies is
|
|
18
|
+
# skipped rather than guessed at, and nothing is loaded twice.
|
|
19
|
+
module ComponentDiscovery
|
|
20
|
+
# Directories scanned when `Woods.configuration.component_paths` is unset.
|
|
21
|
+
# Relative to `Rails.root`, deepest first is not required: the autoload
|
|
22
|
+
# root a file resolves against is chosen per file, longest match first.
|
|
23
|
+
DEFAULT_COMPONENT_PATHS = %w[
|
|
24
|
+
app/components
|
|
25
|
+
app/views/components
|
|
26
|
+
app/views
|
|
27
|
+
].freeze
|
|
28
|
+
|
|
29
|
+
# Ask the autoloader for every component file's constant.
|
|
30
|
+
#
|
|
31
|
+
# Call before reading `descendants`. Cheap when the app has no component
|
|
32
|
+
# directories: each miss is one `File.directory?`.
|
|
33
|
+
#
|
|
34
|
+
# @return [void]
|
|
35
|
+
def load_component_files
|
|
36
|
+
unresolved = 0
|
|
37
|
+
component_directories.each do |directory|
|
|
38
|
+
Dir.glob(File.join(directory, '**', '*.rb')).each do |path|
|
|
39
|
+
unresolved += 1 unless constantize_component_file(path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
return if unresolved.zero?
|
|
43
|
+
|
|
44
|
+
# The one misconfiguration this cannot fix for the caller: a component
|
|
45
|
+
# directory no Rails autoload path owns. Loading those files by hand
|
|
46
|
+
# would define constants Zeitwerk does not manage, so they are skipped,
|
|
47
|
+
# and skipping silently looks identical to having no components.
|
|
48
|
+
Rails.logger.debug do
|
|
49
|
+
"[Woods] #{unresolved} component file(s) resolved to no constant; " \
|
|
50
|
+
'their directory is not on an autoload path.'
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Array<String>] absolute directories to walk, nested entries
|
|
55
|
+
# collapsed into their ancestor so no file is handed over twice
|
|
56
|
+
def component_directories
|
|
57
|
+
present = component_paths
|
|
58
|
+
.map { |relative| File.join(Rails.root.to_s, relative) }
|
|
59
|
+
.uniq
|
|
60
|
+
.select { |directory| File.directory?(directory) }
|
|
61
|
+
|
|
62
|
+
present.reject do |directory|
|
|
63
|
+
present.any? { |other| other != directory && directory.start_with?("#{other}#{File::SEPARATOR}") }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Nil means "the defaults". An explicitly empty list means "walk
|
|
68
|
+
# nothing", which is how an app opts out of the walk entirely.
|
|
69
|
+
#
|
|
70
|
+
# @return [Array<String>] directories to scan, relative to Rails.root
|
|
71
|
+
def component_paths
|
|
72
|
+
configured = Woods.configuration&.component_paths
|
|
73
|
+
configured.nil? ? DEFAULT_COMPONENT_PATHS : Array(configured)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# @param path [String] absolute path to a Ruby file
|
|
79
|
+
# @return [Boolean] false when the path implies no constant Zeitwerk owns
|
|
80
|
+
def constantize_component_file(path)
|
|
81
|
+
name = component_constant_name(path)
|
|
82
|
+
return false unless name
|
|
83
|
+
|
|
84
|
+
name.safe_constantize
|
|
85
|
+
true
|
|
86
|
+
rescue StandardError, ScriptError => e
|
|
87
|
+
# A component that cannot load must not abort the extraction; a
|
|
88
|
+
# SyntaxError in one file would otherwise take the whole run with it.
|
|
89
|
+
Rails.logger.warn("[Woods] Could not load component file #{path}: #{e.message}")
|
|
90
|
+
true
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The constant a file's path implies, under the autoload root that owns
|
|
94
|
+
# it. Nil when no autoload root does, because then the file's name says
|
|
95
|
+
# nothing about its constant and loading it by hand would define
|
|
96
|
+
# constants Zeitwerk does not manage.
|
|
97
|
+
#
|
|
98
|
+
# @param path [String]
|
|
99
|
+
# @return [String, nil]
|
|
100
|
+
def component_constant_name(path)
|
|
101
|
+
root = autoload_roots.find { |candidate| path.start_with?("#{candidate}#{File::SEPARATOR}") }
|
|
102
|
+
return nil unless root
|
|
103
|
+
|
|
104
|
+
path.delete_prefix("#{root}#{File::SEPARATOR}").delete_suffix('.rb').camelize
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Every directory Zeitwerk resolves constants against, longest first so a
|
|
108
|
+
# file under `app/views/components` resolves against that root rather
|
|
109
|
+
# than against `app/views` when an app registers both.
|
|
110
|
+
#
|
|
111
|
+
# @return [Array<String>]
|
|
112
|
+
def autoload_roots
|
|
113
|
+
@autoload_roots ||= begin
|
|
114
|
+
config = Rails.application&.config
|
|
115
|
+
roots = %i[autoload_paths eager_load_paths autoload_once_paths].flat_map do |key|
|
|
116
|
+
config.respond_to?(key) ? Array(config.public_send(key)).map(&:to_s) : []
|
|
117
|
+
end
|
|
118
|
+
roots.uniq.sort_by { |path| -path.length }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'shared_utility_methods'
|
|
4
4
|
require_relative 'shared_dependency_scanner'
|
|
5
|
+
require_relative 'source_nesting'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Extractors
|
|
@@ -25,6 +26,7 @@ module Woods
|
|
|
25
26
|
class ConcernExtractor
|
|
26
27
|
include SharedUtilityMethods
|
|
27
28
|
include SharedDependencyScanner
|
|
29
|
+
include SourceNesting
|
|
28
30
|
|
|
29
31
|
# Canonical concern directories (used as fallback if glob finds nothing).
|
|
30
32
|
CONCERN_DIRECTORIES = %w[
|
|
@@ -89,13 +91,25 @@ module Woods
|
|
|
89
91
|
|
|
90
92
|
# Extract the module name from source or infer from file path.
|
|
91
93
|
#
|
|
94
|
+
# Zeitwerk-governed naming first (G-1): a file under a managed autoload
|
|
95
|
+
# path is named for the constant its path spells, which is what lets a
|
|
96
|
+
# concern under a mid-path `concerns/` segment (`app/models/outer/
|
|
97
|
+
# concerns/leaf.rb`, a real namespace, not an autoload root) survive a
|
|
98
|
+
# one-line class declared above it — the outer-module scan below stops
|
|
99
|
+
# at the first non-module line and would name the wrapper.
|
|
100
|
+
#
|
|
101
|
+
# Otherwise the position-aware scan names the concern by its outer
|
|
102
|
+
# module chain — `module Gateway; module Stripe; module Refundable`
|
|
103
|
+
# yields +Gateway::Stripe::Refundable+ — and never by an inner mixin
|
|
104
|
+
# module: `module Trackable; module ClassMethods` yields +Trackable+,
|
|
105
|
+
# where the old innermost-declaration scan yielded +ClassMethods+ (#174).
|
|
106
|
+
#
|
|
92
107
|
# @param file_path [String] Path to the concern file
|
|
93
108
|
# @param source [String] Ruby source code
|
|
94
109
|
# @return [String, nil] The module name
|
|
95
110
|
def extract_module_name(file_path, source)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return modules.last if modules.any?
|
|
111
|
+
qualified = governed_class_name(file_path, source) || qualified_outer_module_name(source)
|
|
112
|
+
return qualified if qualified
|
|
99
113
|
|
|
100
114
|
# Infer from file path — strip everything up to and including the first concerns/ dir.
|
|
101
115
|
# Handles canonical (app/models/concerns/) and nested (app/models/foo/concerns/) paths.
|