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
@@ -34,12 +34,17 @@ module Woods
34
34
  #
35
35
  # @return [Array<ExtractedUnit>] List of channel units
36
36
  def extract_all
37
- return [] unless action_cable_available?
37
+ discoverable_classes.filter_map { |klass| extract_channel(klass) }
38
+ end
38
39
 
39
- channels = channel_descendants
40
- return [] if channels.empty?
40
+ # The channel classes this extractor would extract from the running app.
41
+ # Shared with the incremental path's class reconciliation (#164).
42
+ #
43
+ # @return [Array<Class>]
44
+ def discoverable_classes
45
+ return [] unless action_cable_available?
41
46
 
42
- channels.filter_map { |klass| extract_channel(klass) }
47
+ channel_descendants
43
48
  end
44
49
 
45
50
  # Extract a single channel class into an ExtractedUnit.
@@ -35,12 +35,30 @@ module Woods
35
35
  file, _line = source_location
36
36
  return nil unless File.exist?(file)
37
37
 
38
- source = File.read(file)
39
- Ast::MethodExtractor.new.extract_method_source(source, action.to_s)
38
+ action_sources_for(file)[action.to_s]
40
39
  rescue StandardError => e
41
40
  Rails.logger.debug("Could not extract action source for #{klass}##{action}: #{e.message}")
42
41
  nil
43
42
  end
43
+
44
+ # Every instance-method source in one file, keyed by method name — read
45
+ # and parsed once per file per extractor instance (audit P1).
46
+ #
47
+ # {#build_action_chunks} in the including extractors calls
48
+ # {#extract_action_source} once per action, so a 30-action controller
49
+ # previously read and re-parsed its own source 30 times. The parse
50
+ # result is a pure function of the file's bytes, so answering every
51
+ # action from one parse is byte-identical to parsing per action.
52
+ #
53
+ # A file whose source fails to parse stays uncached (the `||=` never
54
+ # assigns), so each action retries it and rescues exactly as before.
55
+ #
56
+ # @param file [String] Absolute path of the defining file
57
+ # @return [Hash{String => String, nil}] method name => source text
58
+ def action_sources_for(file)
59
+ (@action_sources ||= {})[file] ||=
60
+ Ast::MethodExtractor.new.extract_method_sources(File.read(file))
61
+ end
44
62
  end
45
63
  end
46
64
  end
@@ -159,7 +159,12 @@ module Woods
159
159
 
160
160
  # Extract individual cache call entries from source.
161
161
  #
162
- # Each entry has :type, :key_pattern, and :ttl.
162
+ # Each entry has :type, :key_pattern, and :ttl, parsed from that
163
+ # occurrence's own argument text (the remainder of its line) — not
164
+ # from the whole file. Matching against the whole source gave every
165
+ # entry the first call's key and the first `expires_in:` in the file
166
+ # (#201); a call without its own `expires_in:` gets a nil ttl rather
167
+ # than inheriting a neighbor's.
163
168
  #
164
169
  # @param source [String] Source code
165
170
  # @return [Array<Hash>] Cache call descriptors
@@ -167,36 +172,65 @@ module Woods
167
172
  calls = []
168
173
 
169
174
  CACHE_PATTERNS.each do |type, pattern|
170
- source.scan(pattern) do
171
- key = extract_key_pattern(source, type)
172
- ttl = extract_ttl(source)
173
- calls << { type: type, key_pattern: key, ttl: ttl }
175
+ each_occurrence(source, pattern) do |offset|
176
+ call_text = call_argument_text(source, offset)
177
+ calls << {
178
+ type: type,
179
+ key_pattern: extract_key_pattern(call_text, type),
180
+ ttl: extract_ttl(call_text)
181
+ }
174
182
  end
175
183
  end
176
184
 
177
185
  calls
178
186
  end
179
187
 
188
+ # Yield the start offset of every non-overlapping occurrence of a pattern.
189
+ #
190
+ # @param source [String] Source code
191
+ # @param pattern [Regexp] Pattern to scan for
192
+ # @yieldparam offset [Integer] Character offset where the occurrence begins
193
+ # @return [void]
194
+ def each_occurrence(source, pattern)
195
+ offset = 0
196
+ while (match = source.match(pattern, offset))
197
+ yield match.begin(0)
198
+ offset = [match.end(0), match.begin(0) + 1].max
199
+ end
200
+ end
201
+
202
+ # The argument text belonging to one cache call occurrence: the
203
+ # remainder of the line the call starts on. Bounding the slice to the
204
+ # line keeps key/TTL parsing from reading a neighboring call's options.
205
+ #
206
+ # @param source [String] Source code
207
+ # @param offset [Integer] Character offset where the occurrence begins
208
+ # @return [String] The call's own argument text
209
+ def call_argument_text(source, offset)
210
+ line_end = source.index("\n", offset) || source.length
211
+ source[offset...line_end]
212
+ end
213
+
180
214
  # Extract the key pattern for a Rails.cache call.
181
215
  #
182
216
  # Returns a simplified string representation of the first argument.
183
217
  #
184
- # @param source [String] Source code
218
+ # @param call_text [String] The call's own argument text
185
219
  # @param type [Symbol] The cache call type
186
220
  # @return [String, nil] The key pattern or nil
187
- def extract_key_pattern(source, type)
221
+ def extract_key_pattern(call_text, type)
188
222
  return nil unless %i[fetch read write delete exist].include?(type)
189
223
 
190
- match = source.match(KEY_PATTERN)
224
+ match = call_text.match(KEY_PATTERN)
191
225
  match ? match[1].strip[0, 60] : nil
192
226
  end
193
227
 
194
- # Extract TTL value from expires_in option.
228
+ # Extract TTL value from the call's own expires_in option.
195
229
  #
196
- # @param source [String] Source code
230
+ # @param call_text [String] The call's own argument text
197
231
  # @return [String, nil] The TTL expression or nil
198
- def extract_ttl(source)
199
- match = source.match(TTL_PATTERN)
232
+ def extract_ttl(call_text)
233
+ match = call_text.match(TTL_PATTERN)
200
234
  match ? match[1].strip : nil
201
235
  end
202
236
 
@@ -3,6 +3,8 @@
3
3
  require 'set'
4
4
  require_relative '../ast/parser'
5
5
  require_relative '../flow_analysis/operation_extractor'
6
+ require_relative 'line_neutralizer'
7
+ require_relative 'reference_patterns'
6
8
 
7
9
  module Woods
8
10
  module Extractors
@@ -32,7 +34,15 @@ module Woods
32
34
  MULTI_COLUMN_WRITERS = %w[update_columns assign_attributes].freeze
33
35
 
34
36
  # Async enqueue methods that indicate a job is being dispatched.
35
- ASYNC_METHODS = %w[perform_later perform_async perform_in perform_at].freeze
37
+ ASYNC_METHODS = ReferencePatterns::ENQUEUE_METHODS
38
+
39
+ # Assignment to a column through +self+: plain, conditional (`||=`,
40
+ # `&&=`), or arithmetic (`+=`, `-=`, `*=`, `/=`). `self.token ||=
41
+ # SecureRandom.uuid` in a before_create is arguably the most common
42
+ # callback body in Rails and reported no write at all (EXTA-6). The
43
+ # lookahead rejects `==` (comparison) and `=~` (regex match), the
44
+ # latter having been read as a write.
45
+ SELF_ASSIGNMENT_PATTERN = %r{self\.(\w+)\s*(?:\|\||&&|[+\-*/])?=(?!=|~)}
36
46
 
37
47
  # Pre-compiled regex patterns (avoid dynamic regex construction in hot loops)
38
48
  SINGLE_COLUMN_WRITER_PATTERNS = SINGLE_COLUMN_WRITERS.to_h do |w|
@@ -43,7 +53,7 @@ module Woods
43
53
  [w, /\b#{Regexp.escape(w)}\s*\(([^)]+)\)/m]
44
54
  end.freeze
45
55
 
46
- ASYNC_PATTERN = /(\w+(?:Job|Worker))\.(?:#{ASYNC_METHODS.map { |m| Regexp.escape(m) }.join('|')})/
56
+ ASYNC_PATTERN = ReferencePatterns::JOB_ENQUEUE
47
57
 
48
58
  DB_READ_PATTERNS = DB_READ_METHODS.to_h do |m|
49
59
  [m, /\.#{Regexp.escape(m)}\b/]
@@ -74,7 +84,7 @@ module Woods
74
84
 
75
85
  return callback_hash.merge(side_effects: empty_side_effects) if method_node.nil?
76
86
 
77
- method_source = method_source_from_node(method_node)
87
+ method_source = scannable_source(method_source_from_node(method_node))
78
88
  return callback_hash.merge(side_effects: empty_side_effects) if method_source.nil?
79
89
 
80
90
  callback_hash.merge(
@@ -130,6 +140,24 @@ module Woods
130
140
  lines[start_idx..end_idx].join
131
141
  end
132
142
 
143
+ # Neutralize a method body before the regex scans run over it.
144
+ #
145
+ # The body is located by AST but scanned by regex, so
146
+ # `logger.info "self.status = pending"` and a commented-out
147
+ # `# self.status = 'x'` both reported a column write (EXTA-8).
148
+ # {LineNeutralizer} blanks comment and string-literal bodies, which is
149
+ # exactly the text a side-effect scan must not believe; identifiers,
150
+ # call chains, and symbols — everything the patterns actually match —
151
+ # survive untouched.
152
+ #
153
+ # @param method_source [String, nil]
154
+ # @return [String, nil]
155
+ def scannable_source(method_source)
156
+ return nil if method_source.nil?
157
+
158
+ LineNeutralizer.neutralize_lines(method_source).join
159
+ end
160
+
133
161
  # Check if a filter string looks like a valid Ruby method name.
134
162
  # Rejects proc/lambda string representations and other non-method filters.
135
163
  #
@@ -150,8 +178,8 @@ module Woods
150
178
  def detect_columns_written(method_source)
151
179
  columns = Set.new
152
180
 
153
- # Pattern: self.col = value (direct assignment, not ==)
154
- method_source.scan(/self\.(\w+)\s*=(?!=)/).flatten.each do |col|
181
+ # Pattern: self.col = value (plain, ||=, &&=, +=, … — never == or =~)
182
+ method_source.scan(SELF_ASSIGNMENT_PATTERN).flatten.each do |col|
155
183
  columns << col if @column_names.include?(col)
156
184
  end
157
185
 
@@ -186,22 +214,24 @@ module Woods
186
214
 
187
215
  # Detect service objects called by the callback method.
188
216
  #
189
- # Matches classes ending in Service followed by a method call.
217
+ # Matches classes ending in Service followed by a method call,
218
+ # namespace included (EXTA-2).
190
219
  #
191
220
  # @param method_source [String]
192
221
  # @return [Array<String>]
193
222
  def detect_services_called(method_source)
194
- method_source.scan(/(\w+Service)(?:\.|::)/).flatten.uniq.sort
223
+ method_source.scan(ReferencePatterns::SERVICE_REFERENCE).flatten.uniq.sort
195
224
  end
196
225
 
197
226
  # Detect mailers triggered by the callback method.
198
227
  #
199
- # Matches classes ending in Mailer followed by a method call.
228
+ # Matches classes ending in Mailer followed by a method call,
229
+ # namespace included (EXTA-2).
200
230
  #
201
231
  # @param method_source [String]
202
232
  # @return [Array<String>]
203
233
  def detect_mailers_triggered(method_source)
204
- method_source.scan(/(\w+Mailer)\./).flatten.uniq.sort
234
+ method_source.scan(ReferencePatterns::MAILER_REFERENCE).flatten.uniq.sort
205
235
  end
206
236
 
207
237
  # Detect database read operations in the callback method.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Extractors
5
+ # Finds component classes the eager load never reached.
6
+ #
7
+ # Both component extractors discover their units through
8
+ # `component_base.descendants`, which only knows the classes something has
9
+ # already loaded. Rails leaves `app/views` out of both `autoload_paths` and
10
+ # `eager_load_paths`, so an app that keeps components beside their
11
+ # templates and opts the subtree into autoloading gets classes that exist
12
+ # on disk, resolve by name, and are absent from `descendants` for the whole
13
+ # extraction (B-184).
14
+ #
15
+ # This walks the configured component directories and asks the autoloader
16
+ # for each file's constant before `descendants` is read. Zeitwerk does the
17
+ # loading, so a file that does not define the constant its path implies is
18
+ # skipped rather than guessed at, and nothing is loaded twice.
19
+ module ComponentDiscovery
20
+ # Directories scanned when `Woods.configuration.component_paths` is unset.
21
+ # Relative to `Rails.root`, deepest first is not required: the autoload
22
+ # root a file resolves against is chosen per file, longest match first.
23
+ DEFAULT_COMPONENT_PATHS = %w[
24
+ app/components
25
+ app/views/components
26
+ app/views
27
+ ].freeze
28
+
29
+ # Ask the autoloader for every component file's constant.
30
+ #
31
+ # Call before reading `descendants`. Cheap when the app has no component
32
+ # directories: each miss is one `File.directory?`.
33
+ #
34
+ # @return [void]
35
+ def load_component_files
36
+ unresolved = 0
37
+ component_directories.each do |directory|
38
+ Dir.glob(File.join(directory, '**', '*.rb')).each do |path|
39
+ unresolved += 1 unless constantize_component_file(path)
40
+ end
41
+ end
42
+ return if unresolved.zero?
43
+
44
+ # The one misconfiguration this cannot fix for the caller: a component
45
+ # directory no Rails autoload path owns. Loading those files by hand
46
+ # would define constants Zeitwerk does not manage, so they are skipped,
47
+ # and skipping silently looks identical to having no components.
48
+ Rails.logger.debug do
49
+ "[Woods] #{unresolved} component file(s) resolved to no constant; " \
50
+ 'their directory is not on an autoload path.'
51
+ end
52
+ end
53
+
54
+ # @return [Array<String>] absolute directories to walk, nested entries
55
+ # collapsed into their ancestor so no file is handed over twice
56
+ def component_directories
57
+ present = component_paths
58
+ .map { |relative| File.join(Rails.root.to_s, relative) }
59
+ .uniq
60
+ .select { |directory| File.directory?(directory) }
61
+
62
+ present.reject do |directory|
63
+ present.any? { |other| other != directory && directory.start_with?("#{other}#{File::SEPARATOR}") }
64
+ end
65
+ end
66
+
67
+ # Nil means "the defaults". An explicitly empty list means "walk
68
+ # nothing", which is how an app opts out of the walk entirely.
69
+ #
70
+ # @return [Array<String>] directories to scan, relative to Rails.root
71
+ def component_paths
72
+ configured = Woods.configuration&.component_paths
73
+ configured.nil? ? DEFAULT_COMPONENT_PATHS : Array(configured)
74
+ end
75
+
76
+ private
77
+
78
+ # @param path [String] absolute path to a Ruby file
79
+ # @return [Boolean] false when the path implies no constant Zeitwerk owns
80
+ def constantize_component_file(path)
81
+ name = component_constant_name(path)
82
+ return false unless name
83
+
84
+ name.safe_constantize
85
+ true
86
+ rescue StandardError, ScriptError => e
87
+ # A component that cannot load must not abort the extraction; a
88
+ # SyntaxError in one file would otherwise take the whole run with it.
89
+ Rails.logger.warn("[Woods] Could not load component file #{path}: #{e.message}")
90
+ true
91
+ end
92
+
93
+ # The constant a file's path implies, under the autoload root that owns
94
+ # it. Nil when no autoload root does, because then the file's name says
95
+ # nothing about its constant and loading it by hand would define
96
+ # constants Zeitwerk does not manage.
97
+ #
98
+ # @param path [String]
99
+ # @return [String, nil]
100
+ def component_constant_name(path)
101
+ root = autoload_roots.find { |candidate| path.start_with?("#{candidate}#{File::SEPARATOR}") }
102
+ return nil unless root
103
+
104
+ path.delete_prefix("#{root}#{File::SEPARATOR}").delete_suffix('.rb').camelize
105
+ end
106
+
107
+ # Every directory Zeitwerk resolves constants against, longest first so a
108
+ # file under `app/views/components` resolves against that root rather
109
+ # than against `app/views` when an app registers both.
110
+ #
111
+ # @return [Array<String>]
112
+ def autoload_roots
113
+ @autoload_roots ||= begin
114
+ config = Rails.application&.config
115
+ roots = %i[autoload_paths eager_load_paths autoload_once_paths].flat_map do |key|
116
+ config.respond_to?(key) ? Array(config.public_send(key)).map(&:to_s) : []
117
+ end
118
+ roots.uniq.sort_by { |path| -path.length }
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'shared_utility_methods'
4
4
  require_relative 'shared_dependency_scanner'
5
+ require_relative 'source_nesting'
5
6
 
6
7
  module Woods
7
8
  module Extractors
@@ -25,6 +26,7 @@ module Woods
25
26
  class ConcernExtractor
26
27
  include SharedUtilityMethods
27
28
  include SharedDependencyScanner
29
+ include SourceNesting
28
30
 
29
31
  # Canonical concern directories (used as fallback if glob finds nothing).
30
32
  CONCERN_DIRECTORIES = %w[
@@ -89,13 +91,25 @@ module Woods
89
91
 
90
92
  # Extract the module name from source or infer from file path.
91
93
  #
94
+ # Zeitwerk-governed naming first (G-1): a file under a managed autoload
95
+ # path is named for the constant its path spells, which is what lets a
96
+ # concern under a mid-path `concerns/` segment (`app/models/outer/
97
+ # concerns/leaf.rb`, a real namespace, not an autoload root) survive a
98
+ # one-line class declared above it — the outer-module scan below stops
99
+ # at the first non-module line and would name the wrapper.
100
+ #
101
+ # Otherwise the position-aware scan names the concern by its outer
102
+ # module chain — `module Gateway; module Stripe; module Refundable`
103
+ # yields +Gateway::Stripe::Refundable+ — and never by an inner mixin
104
+ # module: `module Trackable; module ClassMethods` yields +Trackable+,
105
+ # where the old innermost-declaration scan yielded +ClassMethods+ (#174).
106
+ #
92
107
  # @param file_path [String] Path to the concern file
93
108
  # @param source [String] Ruby source code
94
109
  # @return [String, nil] The module name
95
110
  def extract_module_name(file_path, source)
96
- # Try to find the outermost module definition
97
- modules = source.scan(/^\s*module\s+([\w:]+)/).flatten
98
- return modules.last if modules.any?
111
+ qualified = governed_class_name(file_path, source) || qualified_outer_module_name(source)
112
+ return qualified if qualified
99
113
 
100
114
  # Infer from file path — strip everything up to and including the first concerns/ dir.
101
115
  # Handles canonical (app/models/concerns/) and nested (app/models/foo/concerns/) paths.