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
|
@@ -41,6 +41,11 @@ module Woods
|
|
|
41
41
|
# Environment keys to unwrap when nested in YAML
|
|
42
42
|
ENVIRONMENT_KEYS = %w[production development test staging].freeze
|
|
43
43
|
|
|
44
|
+
# A quoted Whenever command argument in either style. The body runs to
|
|
45
|
+
# the *matching* delimiter, so the inner quotes of `command "echo 'hi'"`
|
|
46
|
+
# stay part of the command.
|
|
47
|
+
QUOTED_ARGUMENT = /(['"])((?:(?!\1).)+)\1/
|
|
48
|
+
|
|
44
49
|
def initialize
|
|
45
50
|
@schedule_files = SCHEDULE_FILES.each_with_object({}) do |(relative_path, format), hash|
|
|
46
51
|
full_path = Rails.root.join(relative_path)
|
|
@@ -63,10 +68,9 @@ module Woods
|
|
|
63
68
|
# this returns an Array because each schedule file contains multiple entries.
|
|
64
69
|
#
|
|
65
70
|
# @param file_path [String] Path to the schedule file
|
|
66
|
-
# @param format [Symbol
|
|
71
|
+
# @param format [Symbol] One of :solid_queue, :sidekiq_cron, :whenever
|
|
67
72
|
# @return [Array<ExtractedUnit>] List of scheduled job units
|
|
68
|
-
def extract_scheduled_job_file(file_path, format
|
|
69
|
-
format ||= infer_format(file_path)
|
|
73
|
+
def extract_scheduled_job_file(file_path, format)
|
|
70
74
|
case format
|
|
71
75
|
when :solid_queue, :sidekiq_cron
|
|
72
76
|
extract_yaml_schedule(file_path, format)
|
|
@@ -93,7 +97,12 @@ module Woods
|
|
|
93
97
|
# @return [Array<ExtractedUnit>]
|
|
94
98
|
def extract_yaml_schedule(file_path, format)
|
|
95
99
|
source = File.read(file_path)
|
|
96
|
-
|
|
100
|
+
# aliases: true — schedule files commonly share defaults via anchors
|
|
101
|
+
# (`<<: *defaults`); without it Psych 4+ raises AliasesNotEnabled and
|
|
102
|
+
# the whole file is silently dropped (#203). Alias expansion only
|
|
103
|
+
# re-references the scalar/hash types already permitted, so it adds
|
|
104
|
+
# no deserialization risk.
|
|
105
|
+
data = YAML.safe_load(source, permitted_classes: [Symbol], aliases: true)
|
|
97
106
|
|
|
98
107
|
return [] unless data.is_a?(Hash) && data.any?
|
|
99
108
|
|
|
@@ -109,17 +118,28 @@ module Woods
|
|
|
109
118
|
|
|
110
119
|
# Detect and unwrap environment-nested YAML.
|
|
111
120
|
#
|
|
112
|
-
# If the top-level keys are environment names (production, development,
|
|
113
|
-
# unwrap to the
|
|
121
|
+
# If the top-level keys are environment names (production, development,
|
|
122
|
+
# etc.), unwrap to the section for the environment being extracted,
|
|
123
|
+
# falling back to the first section when the current environment has no
|
|
124
|
+
# entry. Taking `values.first` unconditionally meant a file listing
|
|
125
|
+
# `development:` before `production:` indexed the development schedule
|
|
126
|
+
# and dropped production entirely (EXTB-19).
|
|
114
127
|
#
|
|
115
128
|
# @param data [Hash] Parsed YAML data
|
|
116
129
|
# @return [Hash] Unwrapped entries
|
|
117
130
|
def unwrap_environment_nesting(data)
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
131
|
+
return data unless data.keys.all? { |k| ENVIRONMENT_KEYS.include?(k.to_s) }
|
|
132
|
+
|
|
133
|
+
data[current_environment] || data.values.first || {}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The environment name the extraction is running under, as a String.
|
|
137
|
+
#
|
|
138
|
+
# @return [String, nil]
|
|
139
|
+
def current_environment
|
|
140
|
+
Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
|
|
141
|
+
rescue StandardError
|
|
142
|
+
nil
|
|
123
143
|
end
|
|
124
144
|
|
|
125
145
|
# Build an ExtractedUnit from a YAML schedule entry.
|
|
@@ -188,12 +208,20 @@ module Woods
|
|
|
188
208
|
|
|
189
209
|
# Parse `every ... do ... end` blocks from Whenever DSL.
|
|
190
210
|
#
|
|
211
|
+
# The terminator is line-anchored (`^\s*end\s*$`), not the bare
|
|
212
|
+
# substring `end` — that substring also occurs inside identifiers
|
|
213
|
+
# (CalendarSyncJob, WeekendDigest), truncating the body so command
|
|
214
|
+
# detection failed (#204). A nested `do ... end` inside an `every`
|
|
215
|
+
# block still terminates the body at the nested block's own `end`
|
|
216
|
+
# line; commands appearing before the nested block are detected,
|
|
217
|
+
# anything after it is not.
|
|
218
|
+
#
|
|
191
219
|
# @param source [String] Ruby source code
|
|
192
220
|
# @return [Array<Hash>] Parsed block data
|
|
193
221
|
def parse_whenever_blocks(source)
|
|
194
222
|
blocks = []
|
|
195
|
-
# Match: every <frequency>[, options] do ... end
|
|
196
|
-
source.scan(/every\s+(.+?)\s+do\s*\n(.*?)end
|
|
223
|
+
# Match: every <frequency>[, options] do ... end (end on its own line)
|
|
224
|
+
source.scan(/every\s+(.+?)\s+do\s*\n(.*?)^\s*end\s*$/m) do |frequency_str, body|
|
|
197
225
|
# Clean up the frequency — strip trailing options like ", at: '...'"
|
|
198
226
|
frequency = frequency_str.strip.sub(/,\s*at:.*\z/, '').strip
|
|
199
227
|
|
|
@@ -214,16 +242,20 @@ module Woods
|
|
|
214
242
|
|
|
215
243
|
# Detect the command type inside a Whenever block body.
|
|
216
244
|
#
|
|
245
|
+
# Both quote styles are accepted (EXTB-12): double-quote-only regexes
|
|
246
|
+
# left `runner 'CleanupJob.perform_later'` — the frozen-string-literal
|
|
247
|
+
# idiom — as command_type :unknown with no job class and no `:job` edge.
|
|
248
|
+
#
|
|
217
249
|
# @param body [String] Block body content
|
|
218
250
|
# @return [Array<Symbol, String>] Command type and body
|
|
219
251
|
def detect_whenever_command(body)
|
|
220
252
|
case body
|
|
221
|
-
when /runner\s
|
|
222
|
-
[:runner, ::Regexp.last_match(
|
|
223
|
-
when /rake\s
|
|
224
|
-
[:rake, ::Regexp.last_match(
|
|
225
|
-
when /command\s
|
|
226
|
-
[:command, ::Regexp.last_match(
|
|
253
|
+
when /runner\s+#{QUOTED_ARGUMENT}/o
|
|
254
|
+
[:runner, ::Regexp.last_match(2)]
|
|
255
|
+
when /rake\s+#{QUOTED_ARGUMENT}/o
|
|
256
|
+
[:rake, ::Regexp.last_match(2)]
|
|
257
|
+
when /command\s+#{QUOTED_ARGUMENT}/o
|
|
258
|
+
[:command, ::Regexp.last_match(2)]
|
|
227
259
|
else
|
|
228
260
|
[:unknown, body.strip]
|
|
229
261
|
end
|
|
@@ -276,22 +308,6 @@ module Woods
|
|
|
276
308
|
unit
|
|
277
309
|
end
|
|
278
310
|
|
|
279
|
-
# ──────────────────────────────────────────────────────────────────────
|
|
280
|
-
# Format Detection
|
|
281
|
-
# ──────────────────────────────────────────────────────────────────────
|
|
282
|
-
|
|
283
|
-
# Infer the schedule format from the file path.
|
|
284
|
-
#
|
|
285
|
-
# @param file_path [String] Path to the schedule file
|
|
286
|
-
# @return [Symbol] One of :solid_queue, :sidekiq_cron, :whenever
|
|
287
|
-
def infer_format(file_path)
|
|
288
|
-
basename = File.basename(file_path)
|
|
289
|
-
SCHEDULE_FILES.each do |relative, fmt|
|
|
290
|
-
return fmt if basename == File.basename(relative)
|
|
291
|
-
end
|
|
292
|
-
:unknown
|
|
293
|
-
end
|
|
294
|
-
|
|
295
311
|
# ──────────────────────────────────────────────────────────────────────
|
|
296
312
|
# Shared helpers
|
|
297
313
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -145,10 +145,9 @@ module Woods
|
|
|
145
145
|
# ──────────────────────────────────────────────────────────────────────
|
|
146
146
|
|
|
147
147
|
def extract_class_name(file_path, source)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
file_path
|
|
148
|
+
# Zeitwerk-governed naming (G-1), then position-aware (#174), then
|
|
149
|
+
# convention.
|
|
150
|
+
governed_class_name(file_path, source) || qualified_first_class_name(source) || file_path
|
|
152
151
|
.sub("#{Rails.root}/", '')
|
|
153
152
|
.sub(%r{^app/(serializers|blueprinters|decorators)/}, '')
|
|
154
153
|
.sub('.rb', '')
|
|
@@ -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 ServiceExtractor
|
|
26
27
|
include SharedUtilityMethods
|
|
27
28
|
include SharedDependencyScanner
|
|
29
|
+
include SourceNesting
|
|
28
30
|
|
|
29
31
|
# Directories to scan for service objects
|
|
30
32
|
SERVICE_DIRECTORIES = %w[
|
|
@@ -55,7 +57,15 @@ module Woods
|
|
|
55
57
|
# @return [ExtractedUnit, nil] The extracted unit or nil if not a service
|
|
56
58
|
def extract_service_file(file_path)
|
|
57
59
|
source = File.read(file_path)
|
|
58
|
-
|
|
60
|
+
# Zeitwerk-governed naming first (G-1): a file under a managed
|
|
61
|
+
# autoload path is named for the constant its path spells, so
|
|
62
|
+
# `module Domain; class Container; class Parser` names the file's
|
|
63
|
+
# Parser, not the wrapper. Position-aware nesting scan (#174) and the
|
|
64
|
+
# path-based convention remain the fallbacks for unmanaged or
|
|
65
|
+
# unconventional files.
|
|
66
|
+
class_name = governed_class_name(file_path, source) ||
|
|
67
|
+
qualified_first_class_name(source) ||
|
|
68
|
+
extract_class_name(file_path, source, '(?:services|interactors|operations|commands|use_cases)')
|
|
59
69
|
|
|
60
70
|
return nil unless class_name
|
|
61
71
|
return nil if skip_file?(source)
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require 'set'
|
|
4
4
|
require_relative '../model_name_cache'
|
|
5
|
+
require_relative 'line_neutralizer'
|
|
6
|
+
require_relative 'reference_patterns'
|
|
5
7
|
require_relative 'route_helper_resolver'
|
|
6
8
|
|
|
7
9
|
module Woods
|
|
@@ -80,8 +82,8 @@ module Woods
|
|
|
80
82
|
#
|
|
81
83
|
# A naive `gsub(/#.*/, '')` truncates lines like
|
|
82
84
|
# `redirect "/posts#comments"; Post.touch` at the in-string `#`,
|
|
83
|
-
# silently dropping the `Post` reference.
|
|
84
|
-
# tracking quote state so only a genuine (unquoted) `#` starts a
|
|
85
|
+
# silently dropping the `Post` reference. {LineNeutralizer} walks each
|
|
86
|
+
# line tracking quote state so only a genuine (unquoted) `#` starts a
|
|
85
87
|
# comment. Escapes (`\"`, `\'`) inside literals are honored. Heredocs,
|
|
86
88
|
# `%`-literals, and character literals whose char is a quote (`?'`,
|
|
87
89
|
# `?"`) are not modeled — these are rare in the constant-bearing code
|
|
@@ -92,7 +94,7 @@ module Woods
|
|
|
92
94
|
# @param source [String] Ruby source code
|
|
93
95
|
# @return [String] source with unquoted `#` comments removed
|
|
94
96
|
def strip_ruby_line_comments(source)
|
|
95
|
-
|
|
97
|
+
LineNeutralizer.strip_comments(source)
|
|
96
98
|
end
|
|
97
99
|
|
|
98
100
|
# Strip a trailing `#` comment from a single line, ignoring `#` inside
|
|
@@ -101,30 +103,7 @@ module Woods
|
|
|
101
103
|
# @param line [String]
|
|
102
104
|
# @return [String]
|
|
103
105
|
def strip_line_comment(line)
|
|
104
|
-
|
|
105
|
-
in_double = false
|
|
106
|
-
i = 0
|
|
107
|
-
len = line.length
|
|
108
|
-
while i < len
|
|
109
|
-
ch = line[i]
|
|
110
|
-
if (in_single || in_double) && ch == '\\'
|
|
111
|
-
i += 2 # skip escaped char inside a literal
|
|
112
|
-
next
|
|
113
|
-
elsif in_single
|
|
114
|
-
in_single = false if ch == "'"
|
|
115
|
-
elsif in_double
|
|
116
|
-
in_double = false if ch == '"'
|
|
117
|
-
elsif ch == "'"
|
|
118
|
-
in_single = true
|
|
119
|
-
elsif ch == '"'
|
|
120
|
-
in_double = true
|
|
121
|
-
elsif ch == '#'
|
|
122
|
-
trailing = line[i..].end_with?("\n") ? "\n" : ''
|
|
123
|
-
return line[0...i] + trailing
|
|
124
|
-
end
|
|
125
|
-
i += 1
|
|
126
|
-
end
|
|
127
|
-
line
|
|
106
|
+
LineNeutralizer.strip_line_comment(line)
|
|
128
107
|
end
|
|
129
108
|
|
|
130
109
|
# Extract string-literal arguments passed to `.constantize` or
|
|
@@ -154,22 +133,27 @@ module Woods
|
|
|
154
133
|
|
|
155
134
|
# Scan for service object references (e.g., FooService.call, FooService::new).
|
|
156
135
|
#
|
|
136
|
+
# Targets keep their namespace ({ReferencePatterns::SERVICE_REFERENCE}):
|
|
137
|
+
# `Billing::ChargeService.call` records the fully-qualified name the
|
|
138
|
+
# service's own unit is identified by.
|
|
139
|
+
#
|
|
157
140
|
# @param source [String] Ruby source code to scan
|
|
158
141
|
# @param via [Symbol] Relationship label (default: :code_reference)
|
|
159
142
|
# @return [Array<Hash>] Dependency hashes
|
|
160
143
|
def scan_service_dependencies(source, via: :code_reference)
|
|
161
|
-
source.scan(
|
|
144
|
+
source.scan(ReferencePatterns::SERVICE_REFERENCE).flatten.uniq.map do |service|
|
|
162
145
|
{ type: :service, target: service, via: via }
|
|
163
146
|
end
|
|
164
147
|
end
|
|
165
148
|
|
|
166
|
-
# Scan for background job references (e.g., FooJob.perform_later
|
|
149
|
+
# Scan for background job references (e.g., FooJob.perform_later,
|
|
150
|
+
# HardWorker.perform_async, SyncJob.set(wait: 5).perform_later).
|
|
167
151
|
#
|
|
168
152
|
# @param source [String] Ruby source code to scan
|
|
169
153
|
# @param via [Symbol] Relationship label (default: :code_reference)
|
|
170
154
|
# @return [Array<Hash>] Dependency hashes
|
|
171
155
|
def scan_job_dependencies(source, via: :code_reference)
|
|
172
|
-
source.scan(
|
|
156
|
+
source.scan(ReferencePatterns::JOB_ENQUEUE).flatten.uniq.map do |job|
|
|
173
157
|
{ type: :job, target: job, via: via }
|
|
174
158
|
end
|
|
175
159
|
end
|
|
@@ -180,7 +164,7 @@ module Woods
|
|
|
180
164
|
# @param via [Symbol] Relationship label (default: :code_reference)
|
|
181
165
|
# @return [Array<Hash>] Dependency hashes
|
|
182
166
|
def scan_mailer_dependencies(source, via: :code_reference)
|
|
183
|
-
source.scan(
|
|
167
|
+
source.scan(ReferencePatterns::MAILER_REFERENCE).flatten.uniq.map do |mailer|
|
|
184
168
|
{ type: :mailer, target: mailer, via: via }
|
|
185
169
|
end
|
|
186
170
|
end
|
|
@@ -227,9 +211,15 @@ module Woods
|
|
|
227
211
|
ROUTE_HELPER_PATTERN = /\b(\w+)_(path|url)\b/
|
|
228
212
|
|
|
229
213
|
# Match form_with/form_for with a named route helper as the action/url.
|
|
230
|
-
# Scans only within the form opening tag (up to the first `do`, `%>`, or
|
|
231
|
-
# to avoid matching unrelated _path/_url helpers that appear
|
|
232
|
-
|
|
214
|
+
# Scans only within the form opening tag (up to the first `do`, `%>`, or
|
|
215
|
+
# `end`) to avoid matching unrelated _path/_url helpers that appear
|
|
216
|
+
# after the form. The negative lookahead (rather than a `[^%]`
|
|
217
|
+
# character class) is what actually enforces that boundary: a
|
|
218
|
+
# `[^%]*?` scan has nothing bounding it in plain-Ruby sources (Phlex,
|
|
219
|
+
# ViewComponent, mailers) that contain no `%` at all, so a
|
|
220
|
+
# `form_with` with no route-helper argument ran past its own `do`
|
|
221
|
+
# block and matched the next unrelated `_path` call in the method.
|
|
222
|
+
FORM_ACTION_HELPER = /form_(with|for)\b(?:(?!%>|\bdo\b|\bend\b)[\s\S])*?(\w+)_(path|url)/
|
|
233
223
|
|
|
234
224
|
# Scan source for named route helpers and resolve them to controller targets.
|
|
235
225
|
#
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'source_nesting'
|
|
4
|
+
|
|
3
5
|
module Woods
|
|
4
6
|
module Extractors
|
|
5
7
|
# Utility methods shared across multiple extractors.
|
|
@@ -20,6 +22,8 @@ module Woods
|
|
|
20
22
|
# end
|
|
21
23
|
#
|
|
22
24
|
module SharedUtilityMethods
|
|
25
|
+
include SourceNesting
|
|
26
|
+
|
|
23
27
|
# Glob files matching a pattern across a list of directories.
|
|
24
28
|
#
|
|
25
29
|
# Centralizes the `Dir[dir.join('**/*.rb')]` loop duplicated across
|
|
@@ -51,22 +55,30 @@ module Woods
|
|
|
51
55
|
end
|
|
52
56
|
|
|
53
57
|
# Resolve the source file for a class using reliable introspection,
|
|
54
|
-
#
|
|
58
|
+
# preferring app-owned locations ({#app_source?}) over vendor/gem paths.
|
|
55
59
|
#
|
|
56
60
|
# Tier order:
|
|
57
61
|
# 1. +const_source_location+ (returns the class definition site)
|
|
58
62
|
# 2. Instance method source locations (first match wins)
|
|
59
63
|
# 3. Class/singleton method source locations (first match wins)
|
|
64
|
+
# 4. The tier-1 definition site even when it is outside the app, if
|
|
65
|
+
# that file exists — a gem-owned class (an engine model such as
|
|
66
|
+
# +ActiveStorage::Blob+) has no app location at all, and the
|
|
67
|
+
# convention fallback would name a file that does not exist.
|
|
68
|
+
# Path normalization leaves such a path absolute, and
|
|
69
|
+
# `woods:validate` reports it as gem-owned rather than missing.
|
|
60
70
|
#
|
|
61
71
|
# @param klass [Class, Module] The class to resolve
|
|
62
72
|
# @param app_root [String] Rails.root.to_s
|
|
63
73
|
# @param fallback [String] Path to return when resolution fails
|
|
64
74
|
# @return [String] Resolved source path or fallback
|
|
65
75
|
def resolve_source_location(klass, app_root:, fallback:)
|
|
76
|
+
definition_site = nil
|
|
77
|
+
|
|
66
78
|
# Tier 1: const_source_location (most reliable — returns class definition site)
|
|
67
79
|
if Object.respond_to?(:const_source_location) && klass.name
|
|
68
|
-
|
|
69
|
-
return
|
|
80
|
+
definition_site = Object.const_source_location(klass.name)&.first
|
|
81
|
+
return definition_site if app_source?(definition_site, app_root)
|
|
70
82
|
end
|
|
71
83
|
|
|
72
84
|
# Tier 2: Instance methods defined directly on this class
|
|
@@ -81,6 +93,10 @@ module Woods
|
|
|
81
93
|
return loc if app_source?(loc, app_root)
|
|
82
94
|
end
|
|
83
95
|
|
|
96
|
+
# Tier 4: nothing in the app defines it — the real definition site,
|
|
97
|
+
# when it exists, beats a synthesized path that does not.
|
|
98
|
+
return definition_site if definition_site && File.exist?(definition_site)
|
|
99
|
+
|
|
84
100
|
fallback
|
|
85
101
|
rescue StandardError
|
|
86
102
|
fallback
|
|
@@ -94,9 +110,15 @@ module Woods
|
|
|
94
110
|
# (e.g., "policies", "validators", "(?:services|interactors|operations|commands|use_cases)")
|
|
95
111
|
# @return [String] The class name
|
|
96
112
|
def extract_class_name(file_path, source, dir_prefix)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
# Zeitwerk-governed naming first (G-1): a file under a managed
|
|
114
|
+
# autoload path is named for the constant its path spells, so
|
|
115
|
+
# `module Domain; class Container; class Parser` names the file's
|
|
116
|
+
# Parser, not the wrapper. Position-aware (SourceNesting, #174):
|
|
117
|
+
# `module Admin; class Foo` names Admin::Foo, and a helper module
|
|
118
|
+
# nested inside the class does not leak into the identifier.
|
|
119
|
+
governed_class_name(file_path, source) ||
|
|
120
|
+
qualified_first_class_name(source) ||
|
|
121
|
+
file_path.sub("#{Rails.root}/", '').sub(%r{^app/#{dir_prefix}/}, '').sub('.rb', '').camelize
|
|
100
122
|
end
|
|
101
123
|
|
|
102
124
|
# Extract the parent class name from a class definition.
|
|
@@ -245,6 +267,14 @@ module Woods
|
|
|
245
267
|
in_private = false if stripped == 'public'
|
|
246
268
|
in_protected = false if stripped == 'public'
|
|
247
269
|
|
|
270
|
+
# `private def foo` / `protected def foo` mark one method without
|
|
271
|
+
# opening a visibility section. Matching only the bare keyword lines
|
|
272
|
+
# above meant the inline form was invisible, so the `def` that
|
|
273
|
+
# follows on the same line was collected as public — and a method
|
|
274
|
+
# reported public can be misclassified downstream as a decision
|
|
275
|
+
# method. Skip the line without touching section state.
|
|
276
|
+
next if stripped.match?(/\A(?:private|protected)\s+def\b/)
|
|
277
|
+
|
|
248
278
|
if !in_private && !in_protected && stripped =~ /def\s+((?:self\.)?\w+[?!=]?)/
|
|
249
279
|
method_name = ::Regexp.last_match(1)
|
|
250
280
|
methods << method_name unless method_name.start_with?('_')
|