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.
Files changed (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. 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, nil] One of :solid_queue, :sidekiq_cron, :whenever (inferred from filename if nil)
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 = nil)
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
- data = YAML.safe_load(source, permitted_classes: [Symbol])
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, etc.),
113
- # unwrap to the first environment's entries.
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
- if data.keys.all? { |k| ENVIRONMENT_KEYS.include?(k.to_s) }
119
- data.values.first || {}
120
- else
121
- data
122
- end
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/m) do |frequency_str, body|
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(1)]
223
- when /rake\s+"([^"]+)"/
224
- [:rake, ::Regexp.last_match(1)]
225
- when /command\s+"([^"]+)"/
226
- [:command, ::Regexp.last_match(1)]
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
- return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/
149
-
150
- # Fall back to convention
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
- class_name = extract_class_name(file_path, source, '(?:services|interactors|operations|commands|use_cases)')
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. This scanner walks each line
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
- source.each_line.map { |line| strip_line_comment(line) }.join
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
- in_single = false
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(/(\w+Service)(?:\.|::)/).flatten.uniq.map do |service|
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(/(\w+Job)\.perform/).flatten.uniq.map do |job|
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(/(\w+Mailer)\./).flatten.uniq.map do |mailer|
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 `end`)
231
- # to avoid matching unrelated _path/_url helpers that appear after the form.
232
- FORM_ACTION_HELPER = /form_(with|for)\b[^%]*?(\w+)_(path|url)/
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
- # filtered through {#app_source?} to reject vendor/gem paths.
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
- loc = Object.const_source_location(klass.name)&.first
69
- return loc if app_source?(loc, app_root)
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
- return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/
98
-
99
- file_path.sub("#{Rails.root}/", '').sub(%r{^app/#{dir_prefix}/}, '').sub('.rb', '').camelize
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?('_')