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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'reference_patterns'
|
|
3
4
|
require_relative 'shared_utility_methods'
|
|
4
5
|
require_relative 'shared_dependency_scanner'
|
|
5
6
|
|
|
@@ -52,22 +53,36 @@ module Woods
|
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
# Also try class-based discovery for ActiveJob
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
seen << unit.identifier
|
|
64
|
-
end
|
|
56
|
+
seen = units.to_set(&:identifier)
|
|
57
|
+
discoverable_classes.each do |job_class|
|
|
58
|
+
next if seen.include?(job_class.name)
|
|
59
|
+
|
|
60
|
+
unit = extract_job_class(job_class)
|
|
61
|
+
if unit
|
|
62
|
+
units << unit
|
|
63
|
+
seen << unit.identifier
|
|
65
64
|
end
|
|
66
65
|
end
|
|
67
66
|
|
|
68
67
|
units.compact
|
|
69
68
|
end
|
|
70
69
|
|
|
70
|
+
# Job classes the runtime vouches for beyond the job-directory scan:
|
|
71
|
+
# every named ApplicationJob descendant. A job nested inside a class
|
|
72
|
+
# that lives elsewhere (a model file) is reachable only this way.
|
|
73
|
+
#
|
|
74
|
+
# Shared with the incremental path, which re-extracts a job by class
|
|
75
|
+
# when re-deriving it from its file names a different constant — the
|
|
76
|
+
# file's governed name is then the enclosing class, not the job.
|
|
77
|
+
#
|
|
78
|
+
# @return [Array<Class>] named ApplicationJob descendants, or [] when
|
|
79
|
+
# the app defines no ApplicationJob
|
|
80
|
+
def discoverable_classes
|
|
81
|
+
return [] unless defined?(ApplicationJob)
|
|
82
|
+
|
|
83
|
+
ApplicationJob.descendants.reject { |job_class| job_class.name.nil? }
|
|
84
|
+
end
|
|
85
|
+
|
|
71
86
|
# Extract a job from its file
|
|
72
87
|
#
|
|
73
88
|
# @param file_path [String] Path to the job file
|
|
@@ -130,11 +145,9 @@ module Woods
|
|
|
130
145
|
# ──────────────────────────────────────────────────────────────────────
|
|
131
146
|
|
|
132
147
|
def extract_class_name(file_path, source)
|
|
133
|
-
#
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
# Fall back to convention
|
|
137
|
-
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
|
|
138
151
|
.sub("#{Rails.root}/", '')
|
|
139
152
|
.sub(%r{^app/(jobs|workers|sidekiq)/}, '')
|
|
140
153
|
.sub('.rb', '')
|
|
@@ -150,18 +163,27 @@ module Woods
|
|
|
150
163
|
source.match?(/def perform/)
|
|
151
164
|
end
|
|
152
165
|
|
|
153
|
-
# Locate the source file for a job class.
|
|
166
|
+
# Locate the source file for a job class (class-discovery path only).
|
|
154
167
|
#
|
|
155
168
|
# Convention path first, then introspection via {#resolve_source_location}
|
|
156
169
|
# which filters out vendor/node_modules paths.
|
|
157
170
|
#
|
|
171
|
+
# Returns nil rather than a fabricated convention path when nothing
|
|
172
|
+
# resolves — the GraphQL precedent (B-070/#171). `ApplicationJob.descendants`
|
|
173
|
+
# includes gem- and engine-defined classes; registering
|
|
174
|
+
# `app/jobs/<name>.rb` for one puts a nonexistent, rule-claimed path in
|
|
175
|
+
# the graph's file_map, and the next incremental run's safety-net sweep
|
|
176
|
+
# prunes the unit a full extraction still emits (EXTA-3). A nil path
|
|
177
|
+
# never enters file_map, so the sweep cannot reach it;
|
|
178
|
+
# {#extract_job_class} already reads source only when the path exists.
|
|
179
|
+
#
|
|
158
180
|
# @param job_class [Class]
|
|
159
181
|
# @return [String, nil]
|
|
160
182
|
def source_file_for(job_class)
|
|
161
183
|
convention_path = Rails.root.join("app/jobs/#{job_class.name.underscore}.rb").to_s
|
|
162
184
|
return convention_path if File.exist?(convention_path)
|
|
163
185
|
|
|
164
|
-
resolve_source_location(job_class, app_root: Rails.root.to_s, fallback:
|
|
186
|
+
resolve_source_location(job_class, app_root: Rails.root.to_s, fallback: nil)
|
|
165
187
|
end
|
|
166
188
|
|
|
167
189
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -262,8 +284,13 @@ module Woods
|
|
|
262
284
|
def extract_retry_config(source)
|
|
263
285
|
config = {}
|
|
264
286
|
|
|
265
|
-
# ActiveJob retry_on
|
|
266
|
-
|
|
287
|
+
# ActiveJob retry_on. The error capture spans `::` (matching the
|
|
288
|
+
# sibling `extract_retry_on`): `\w+` alone recorded `Net` for
|
|
289
|
+
# `Net::OpenTimeout` and, because the wait/attempts tail is matched
|
|
290
|
+
# against the same token run, dropped both of them (EXTA-10).
|
|
291
|
+
source.scan(
|
|
292
|
+
/retry_on\s+(\w+(?:::\w+)*)(?:,\s*wait:\s*([^,\n]+))?(?:,\s*attempts:\s*(\d+))?/
|
|
293
|
+
) do |error, wait, attempts|
|
|
267
294
|
config[:retry_on] ||= []
|
|
268
295
|
config[:retry_on] << {
|
|
269
296
|
error: error,
|
|
@@ -359,12 +386,15 @@ module Woods
|
|
|
359
386
|
|
|
360
387
|
# Scan source for job class enqueue calls and return the list of enqueued job names.
|
|
361
388
|
#
|
|
389
|
+
# Shares {ReferencePatterns::JOB_ENQUEUE} with the scanner and
|
|
390
|
+
# CallbackAnalyzer so all three sites agree on what an enqueue looks
|
|
391
|
+
# like — `*Worker` classes and namespaced jobs included (EXTA-2/EXTA-4).
|
|
392
|
+
#
|
|
362
393
|
# @param source [String] The job source code
|
|
363
394
|
# @param current_class_name [String, nil] The current job class name (excluded from results)
|
|
364
395
|
# @return [Array<String>] Unique list of enqueued job class names
|
|
365
396
|
def extract_enqueued_jobs(source, current_class_name = nil)
|
|
366
|
-
|
|
367
|
-
job_names = source.scan(pattern).flatten.uniq
|
|
397
|
+
job_names = source.scan(ReferencePatterns::JOB_ENQUEUE).flatten.uniq
|
|
368
398
|
job_names.reject { |name| name == current_class_name }
|
|
369
399
|
end
|
|
370
400
|
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
|
|
@@ -32,6 +33,7 @@ module Woods
|
|
|
32
33
|
class LibExtractor
|
|
33
34
|
include SharedUtilityMethods
|
|
34
35
|
include SharedDependencyScanner
|
|
36
|
+
include SourceNesting
|
|
35
37
|
|
|
36
38
|
# Root directory to scan
|
|
37
39
|
LIB_DIRECTORY = 'lib'
|
|
@@ -107,10 +109,14 @@ module Woods
|
|
|
107
109
|
|
|
108
110
|
# Infer the primary constant name from source or fall back to file path.
|
|
109
111
|
#
|
|
110
|
-
# For files with a class definition,
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
112
|
+
# For files with a class definition, the position-aware nesting scan
|
|
113
|
+
# (SourceNesting) qualifies the first `class` declaration with the
|
|
114
|
+
# modules actually open at that position — a helper module nested
|
|
115
|
+
# inside the class, or a sibling module that closed before the class
|
|
116
|
+
# opened, no longer pollutes the identifier (#174). For module-only
|
|
117
|
+
# files, uses the outer module chain (namespace wrappers joined),
|
|
118
|
+
# ignoring inner modules declared after body content. Falls back to
|
|
119
|
+
# path-based camelize when neither is present.
|
|
114
120
|
#
|
|
115
121
|
# @param file_path [String] Absolute path to the file
|
|
116
122
|
# @param source [String] Ruby source code
|
|
@@ -118,19 +124,19 @@ module Woods
|
|
|
118
124
|
def infer_class_name(file_path, source)
|
|
119
125
|
return nil if source.strip.empty?
|
|
120
126
|
|
|
121
|
-
# Class definition —
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
# Module-only file —
|
|
132
|
-
|
|
133
|
-
return
|
|
127
|
+
# Class definition — Zeitwerk-governed naming first (G-1), then the
|
|
128
|
+
# position-aware nesting scan, which qualifies the first `class`
|
|
129
|
+
# declaration with the modules actually open at that position — a
|
|
130
|
+
# helper module nested inside the class, or a sibling module that
|
|
131
|
+
# closed before the class opened, no longer pollutes the identifier
|
|
132
|
+
# (#174). lib/ files are typically unmanaged, so the governed lookup
|
|
133
|
+
# returns nil and the source scan decides.
|
|
134
|
+
qualified = governed_class_name(file_path, source) || qualified_first_class_name(source)
|
|
135
|
+
return qualified if qualified
|
|
136
|
+
|
|
137
|
+
# Module-only file — the outer module chain
|
|
138
|
+
module_name = qualified_outer_module_name(source)
|
|
139
|
+
return module_name if module_name
|
|
134
140
|
|
|
135
141
|
# Fall back to path-based naming
|
|
136
142
|
path_based_class_name(file_path)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Extractors
|
|
5
|
+
# Quote-aware line preprocessing shared by Woods' line-based parsers.
|
|
6
|
+
#
|
|
7
|
+
# Several extractors (and the chunkers' sibling heuristic) classify a line
|
|
8
|
+
# by matching keywords against it: does it open a block, does it close one,
|
|
9
|
+
# does it declare a constant. Run against raw source those matchers read
|
|
10
|
+
# prose as code — `x.to_s # pad for display` opens a `for` block that
|
|
11
|
+
# nothing closes, `title { "things to do" }` opens a `do` block, and
|
|
12
|
+
# `module Api # rename at the end` looks self-terminated because the
|
|
13
|
+
# comment ends in the word "end".
|
|
14
|
+
#
|
|
15
|
+
# This module centralizes the walker that removes those false signals. The
|
|
16
|
+
# quote-aware comment stripper originated in
|
|
17
|
+
# {SharedDependencyScanner#strip_line_comment}; the string-blanking and
|
|
18
|
+
# heredoc-aware variants are the same walk with the literal bodies erased.
|
|
19
|
+
#
|
|
20
|
+
# Known limitations (deliberate — this is a scanner, not a parser):
|
|
21
|
+
# `%`-literals (`%w[]`, `%q{}`), regular-expression literals, and character
|
|
22
|
+
# literals whose char is a quote (`?'`) are not modeled. Mis-reading one
|
|
23
|
+
# only risks an unstripped comment or an over-blanked line, never a crash.
|
|
24
|
+
#
|
|
25
|
+
# @example Neutralizing a line before a block-opener check
|
|
26
|
+
# LineNeutralizer.neutralize_line('title { "things to do" } # for now')
|
|
27
|
+
# # => "title { \" \" } "
|
|
28
|
+
#
|
|
29
|
+
module LineNeutralizer
|
|
30
|
+
# Start of a heredoc: `<<~SQL`, `<<-EOS`, `<<'RAW'`, `<<~"TEXT"`.
|
|
31
|
+
# The bare (unquoted) form requires the identifier to follow `<<`
|
|
32
|
+
# immediately and start uppercase, which is how Ruby itself
|
|
33
|
+
# distinguishes `x <<HEREDOC` from the `<<` shift operator.
|
|
34
|
+
HEREDOC_START = /<<[~-]?(?:(["'`])([A-Za-z_]\w*)\1|([A-Z_]\w*))/
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Strip a trailing `#` comment from a single line, ignoring `#`
|
|
39
|
+
# characters inside string literals. Preserves the trailing newline.
|
|
40
|
+
#
|
|
41
|
+
# @param line [String]
|
|
42
|
+
# @return [String]
|
|
43
|
+
def strip_line_comment(line)
|
|
44
|
+
walk(line, blank_strings: false).first
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Strip a trailing `#` comment AND blank the contents of string
|
|
48
|
+
# literals, so keywords inside prose cannot be read as code. The
|
|
49
|
+
# delimiters survive (`"…"` stays a string-shaped token); only the
|
|
50
|
+
# bytes between them become spaces. Preserves the trailing newline.
|
|
51
|
+
#
|
|
52
|
+
# @param line [String]
|
|
53
|
+
# @return [String]
|
|
54
|
+
def neutralize_line(line)
|
|
55
|
+
walk(line, blank_strings: true).first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Remove `#` line comments from a whole source without touching `#`
|
|
59
|
+
# characters inside string literals.
|
|
60
|
+
#
|
|
61
|
+
# @param source [String] Ruby source code
|
|
62
|
+
# @return [String]
|
|
63
|
+
def strip_comments(source)
|
|
64
|
+
source.each_line.map { |line| strip_line_comment(line) }.join
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Neutralize a whole source line by line, additionally blanking heredoc
|
|
68
|
+
# bodies (which a single-line walk cannot see). The result has exactly
|
|
69
|
+
# as many lines as the input, so callers can index the two in lockstep:
|
|
70
|
+
# match keywords against the neutralized line, read content from the
|
|
71
|
+
# original.
|
|
72
|
+
#
|
|
73
|
+
# @param source [String] Ruby source code
|
|
74
|
+
# @return [Array<String>] One neutralized line per input line
|
|
75
|
+
def neutralize_lines(source)
|
|
76
|
+
pending = []
|
|
77
|
+
|
|
78
|
+
source.each_line.map do |line|
|
|
79
|
+
if pending.any?
|
|
80
|
+
pending.shift if heredoc_terminator?(line, pending.first)
|
|
81
|
+
blank_line(line)
|
|
82
|
+
else
|
|
83
|
+
neutralized, heredocs = walk(line, blank_strings: true)
|
|
84
|
+
pending.concat(heredocs)
|
|
85
|
+
neutralized
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Walk one line tracking string state.
|
|
91
|
+
#
|
|
92
|
+
# @param line [String]
|
|
93
|
+
# @param blank_strings [Boolean] Replace literal bodies with spaces
|
|
94
|
+
# @return [Array(String, Array<String>)] Processed line and the heredoc
|
|
95
|
+
# identifiers the line opens
|
|
96
|
+
def walk(line, blank_strings:)
|
|
97
|
+
out = +''
|
|
98
|
+
heredocs = []
|
|
99
|
+
quote = nil
|
|
100
|
+
index = 0
|
|
101
|
+
length = line.length
|
|
102
|
+
|
|
103
|
+
while index < length
|
|
104
|
+
char = line[index]
|
|
105
|
+
|
|
106
|
+
if quote
|
|
107
|
+
if char == '\\'
|
|
108
|
+
pair = line[index, 2].to_s
|
|
109
|
+
out << (blank_strings ? blank(pair) : pair)
|
|
110
|
+
index += 2
|
|
111
|
+
next
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
quote = nil if char == quote
|
|
115
|
+
out << (quote && blank_strings ? blank(char) : char)
|
|
116
|
+
elsif char == '#'
|
|
117
|
+
break
|
|
118
|
+
elsif char == '<' && (heredoc = match_heredoc(line, index))
|
|
119
|
+
heredocs << (heredoc[2] || heredoc[3])
|
|
120
|
+
out << heredoc[0]
|
|
121
|
+
index += heredoc[0].length
|
|
122
|
+
next
|
|
123
|
+
else
|
|
124
|
+
quote = char if ['"', "'"].include?(char)
|
|
125
|
+
out << char
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
index += 1
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
out << "\n" if index < length && line.end_with?("\n")
|
|
132
|
+
[out, heredocs]
|
|
133
|
+
end
|
|
134
|
+
private_class_method :walk
|
|
135
|
+
|
|
136
|
+
# Replace every character with a space, keeping newlines so a
|
|
137
|
+
# neutralized line still ends where the original did.
|
|
138
|
+
#
|
|
139
|
+
# @param text [String]
|
|
140
|
+
# @return [String]
|
|
141
|
+
def blank(text)
|
|
142
|
+
text.gsub(/[^\n]/, ' ')
|
|
143
|
+
end
|
|
144
|
+
private_class_method :blank
|
|
145
|
+
|
|
146
|
+
# @param line [String]
|
|
147
|
+
# @param index [Integer] Offset of the `<` character
|
|
148
|
+
# @return [MatchData, nil] Heredoc opener anchored at +index+
|
|
149
|
+
def match_heredoc(line, index)
|
|
150
|
+
match = HEREDOC_START.match(line, index)
|
|
151
|
+
match if match && match.begin(0) == index
|
|
152
|
+
end
|
|
153
|
+
private_class_method :match_heredoc
|
|
154
|
+
|
|
155
|
+
# @param line [String]
|
|
156
|
+
# @param identifier [String] Heredoc terminator being waited on
|
|
157
|
+
# @return [Boolean]
|
|
158
|
+
def heredoc_terminator?(line, identifier)
|
|
159
|
+
line.strip == identifier
|
|
160
|
+
end
|
|
161
|
+
private_class_method :heredoc_terminator?
|
|
162
|
+
|
|
163
|
+
# @param line [String]
|
|
164
|
+
# @return [String] The line's newline, or an empty string
|
|
165
|
+
def blank_line(line)
|
|
166
|
+
line.end_with?("\n") ? "\n" : ''
|
|
167
|
+
end
|
|
168
|
+
private_class_method :blank_line
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -35,11 +35,19 @@ module Woods
|
|
|
35
35
|
#
|
|
36
36
|
# @return [Array<ExtractedUnit>] List of mailer units
|
|
37
37
|
def extract_all
|
|
38
|
-
|
|
38
|
+
discoverable_classes.map do |mailer|
|
|
39
39
|
extract_mailer(mailer)
|
|
40
40
|
end.compact
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# The mailer classes this extractor would extract from the running app.
|
|
44
|
+
# Shared with the incremental path's class reconciliation (#164).
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<Class>]
|
|
47
|
+
def discoverable_classes
|
|
48
|
+
@mailer_base.descendants
|
|
49
|
+
end
|
|
50
|
+
|
|
43
51
|
# Extract a single mailer
|
|
44
52
|
#
|
|
45
53
|
# @param mailer [Class] The mailer class
|
|
@@ -126,12 +126,12 @@ module Woods
|
|
|
126
126
|
return ::Regexp.last_match(1) if source =~ /< DelegateClass\((\w+)\)/
|
|
127
127
|
|
|
128
128
|
# super(model) in initialize
|
|
129
|
-
return ::Regexp.last_match(1)
|
|
129
|
+
return constant_name_for(::Regexp.last_match(1)) if source =~ /super\((\w+)\)/
|
|
130
130
|
|
|
131
131
|
# @model = model; super(model) — look for param name
|
|
132
132
|
if source =~ /def\s+initialize\s*\((\w+)/
|
|
133
133
|
param = ::Regexp.last_match(1)
|
|
134
|
-
return param
|
|
134
|
+
return constant_name_for(param) unless %w[args options params attributes].include?(param)
|
|
135
135
|
end
|
|
136
136
|
|
|
137
137
|
# Infer from class name: OrderManager -> Order
|
|
@@ -143,6 +143,23 @@ module Woods
|
|
|
143
143
|
inferred
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
+
# Turn a local variable / parameter name into the model constant it
|
|
147
|
+
# stands for.
|
|
148
|
+
#
|
|
149
|
+
# `String#capitalize` only upcases the first character and downcases the
|
|
150
|
+
# rest, so `order_item` became `Order_item` — a constant no model has,
|
|
151
|
+
# producing a dangling dependency edge instead of one to `OrderItem`.
|
|
152
|
+
#
|
|
153
|
+
# Falls back to `capitalize` only when ActiveSupport's `camelize` is
|
|
154
|
+
# unavailable, which it never is in a booted host but can be in an
|
|
155
|
+
# isolated unit spec.
|
|
156
|
+
#
|
|
157
|
+
# @param name [String] a snake_case variable or parameter name
|
|
158
|
+
# @return [String] the camelized constant name
|
|
159
|
+
def constant_name_for(name)
|
|
160
|
+
name.respond_to?(:camelize) ? name.camelize : name.split('_').map(&:capitalize).join
|
|
161
|
+
end
|
|
162
|
+
|
|
146
163
|
def detect_delegation_type(source)
|
|
147
164
|
return :delegate_class if source.match?(/< DelegateClass\(/)
|
|
148
165
|
return :simple_delegator if source.match?(/< SimpleDelegator/)
|
|
@@ -36,6 +36,7 @@ module Woods
|
|
|
36
36
|
# DDL operations that take a table name as the first symbol argument
|
|
37
37
|
TABLE_OPERATIONS = %w[
|
|
38
38
|
create_table
|
|
39
|
+
change_table
|
|
39
40
|
drop_table
|
|
40
41
|
rename_table
|
|
41
42
|
add_column
|
|
@@ -56,7 +57,12 @@ module Woods
|
|
|
56
57
|
change_column_null
|
|
57
58
|
].freeze
|
|
58
59
|
|
|
59
|
-
#
|
|
60
|
+
# Block-form DDL openers whose `do |t|` body declares columns. Only
|
|
61
|
+
# `create_table` was scanned, so a `change_table :orders do |t|` block
|
|
62
|
+
# contributed neither a table nor a column (EXTB-18).
|
|
63
|
+
TABLE_BLOCK_OPENERS = '(?:create_table|change_table)'
|
|
64
|
+
|
|
65
|
+
# Column type methods used inside create_table and change_table blocks
|
|
60
66
|
COLUMN_TYPE_METHODS = %w[
|
|
61
67
|
string integer float decimal boolean binary text
|
|
62
68
|
date datetime time timestamp
|
|
@@ -146,10 +152,15 @@ module Woods
|
|
|
146
152
|
|
|
147
153
|
# Check whether the source defines an ActiveRecord::Migration subclass.
|
|
148
154
|
#
|
|
155
|
+
# `[\w:]+`, matching {#extract_class_name}'s class-name pattern — a
|
|
156
|
+
# compact-form declaration (`class Billing::AddFoo < ...`) satisfies
|
|
157
|
+
# extract_class_name but failed the plain `\w+` here, so the file was
|
|
158
|
+
# silently skipped even though it had a resolvable identifier.
|
|
159
|
+
#
|
|
149
160
|
# @param source [String] Ruby source code
|
|
150
161
|
# @return [Boolean]
|
|
151
162
|
def migration_class?(source)
|
|
152
|
-
source.match?(/class\s
|
|
163
|
+
source.match?(/class\s+[\w:]+\s*<\s*ActiveRecord::Migration/)
|
|
153
164
|
end
|
|
154
165
|
|
|
155
166
|
# ──────────────────────────────────────────────────────────────────────
|
|
@@ -260,7 +271,7 @@ module Woods
|
|
|
260
271
|
{ table: table, column: column, type: type }
|
|
261
272
|
end
|
|
262
273
|
|
|
263
|
-
# t.type :column inside create_table blocks
|
|
274
|
+
# t.type :column inside create_table/change_table blocks
|
|
264
275
|
extract_block_columns(source, columns)
|
|
265
276
|
|
|
266
277
|
# t.column :name, :type
|
|
@@ -316,7 +327,7 @@ module Woods
|
|
|
316
327
|
{ table: table, reference: reference }
|
|
317
328
|
end
|
|
318
329
|
|
|
319
|
-
# t.references :ref inside create_table blocks
|
|
330
|
+
# t.references :ref inside create_table/change_table blocks
|
|
320
331
|
extract_block_references(source, refs)
|
|
321
332
|
|
|
322
333
|
refs
|
|
@@ -336,14 +347,14 @@ module Woods
|
|
|
336
347
|
# Block Column / Reference Parsing
|
|
337
348
|
# ──────────────────────────────────────────────────────────────────────
|
|
338
349
|
|
|
339
|
-
# Extract t.type :column declarations inside create_table blocks.
|
|
350
|
+
# Extract t.type :column declarations inside create_table/change_table blocks.
|
|
340
351
|
#
|
|
341
352
|
# @param source [String] Ruby source code
|
|
342
353
|
# @param columns [Array<Hash>] Accumulator array
|
|
343
354
|
# @return [void]
|
|
344
355
|
def extract_block_columns(source, columns)
|
|
345
|
-
# Find create_table blocks and parse t.type :column patterns
|
|
346
|
-
source.scan(
|
|
356
|
+
# Find create_table/change_table blocks and parse t.type :column patterns
|
|
357
|
+
source.scan(/#{TABLE_BLOCK_OPENERS}\s+:(\w+).*?do\s*\|(\w+)\|(.+?)^\s*end/m).each do |table, var, block|
|
|
347
358
|
type_pattern = COLUMN_TYPE_METHODS.join('|')
|
|
348
359
|
block.scan(/#{var}\.(#{type_pattern})\s+:(\w+)/).each do |type, column|
|
|
349
360
|
columns << { table: table, column: column, type: type }
|
|
@@ -351,26 +362,26 @@ module Woods
|
|
|
351
362
|
end
|
|
352
363
|
end
|
|
353
364
|
|
|
354
|
-
# Extract t.column :name, :type declarations inside create_table blocks.
|
|
365
|
+
# Extract t.column :name, :type declarations inside create_table/change_table blocks.
|
|
355
366
|
#
|
|
356
367
|
# @param source [String] Ruby source code
|
|
357
368
|
# @param columns [Array<Hash>] Accumulator array
|
|
358
369
|
# @return [void]
|
|
359
370
|
def extract_explicit_column_calls(source, columns)
|
|
360
|
-
source.scan(
|
|
371
|
+
source.scan(/#{TABLE_BLOCK_OPENERS}\s+:(\w+).*?do\s*\|(\w+)\|(.+?)^\s*end/m).each do |table, var, block|
|
|
361
372
|
block.scan(/#{var}\.column\s+:(\w+)\s*,\s*:(\w+)/).each do |column, type|
|
|
362
373
|
columns << { table: table, column: column, type: type }
|
|
363
374
|
end
|
|
364
375
|
end
|
|
365
376
|
end
|
|
366
377
|
|
|
367
|
-
# Extract t.references declarations inside create_table blocks.
|
|
378
|
+
# Extract t.references declarations inside create_table/change_table blocks.
|
|
368
379
|
#
|
|
369
380
|
# @param source [String] Ruby source code
|
|
370
381
|
# @param refs [Array<Hash>] Accumulator array
|
|
371
382
|
# @return [void]
|
|
372
383
|
def extract_block_references(source, refs)
|
|
373
|
-
source.scan(
|
|
384
|
+
source.scan(/#{TABLE_BLOCK_OPENERS}\s+:(\w+).*?do\s*\|(\w+)\|(.+?)^\s*end/m).each do |table, var, block|
|
|
374
385
|
block.scan(/#{var}\.references\s+:(\w+)/).each do |reference,|
|
|
375
386
|
refs << { table: table, reference: reference }
|
|
376
387
|
end
|