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
@@ -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
- if defined?(ApplicationJob)
56
- seen = units.to_set(&:identifier)
57
- ApplicationJob.descendants.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
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
- # Try to extract from source
134
- return ::Regexp.last_match(1) if source =~ /^\s*class\s+([\w:]+)/
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: convention_path)
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
- source.scan(/retry_on\s+(\w+)(?:,\s*wait:\s*([^,\n]+))?(?:,\s*attempts:\s*(\d+))?/) do |error, wait, attempts|
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
- pattern = /(\w+Job)\.(?:perform_later|perform_async|perform_in|perform_at|set\b)/
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, combines outer module namespaces
111
- # with the class name. For module-only files, uses the outermost module
112
- # name (joined with inner modules). Falls back to path-based camelize
113
- # when neither is present.
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 — combine outer modules + class name
122
- class_match = source.match(/^\s*class\s+([\w:]+)/)
123
- if class_match
124
- base = class_match[1]
125
- return base if base.include?('::')
126
-
127
- namespaces = source.scan(/^\s*module\s+([\w:]+)/).flatten
128
- return namespaces.any? ? "#{namespaces.join('::')}::#{base}" : base
129
- end
130
-
131
- # Module-only file — use the outermost module chain
132
- modules = source.scan(/^\s*module\s+([\w:]+)/).flatten
133
- return modules.join('::') if modules.any?
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
- @mailer_base.descendants.map do |mailer|
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).capitalize if source =~ /super\((\w+)\)/
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.capitalize unless %w[args options params attributes].include?(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
- # Column type methods used inside create_table blocks
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+\w+\s*<\s*ActiveRecord::Migration/)
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(/create_table\s+:(\w+).*?do\s*\|(\w+)\|(.+?)^\s*end/m).each do |table, var, block|
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(/create_table\s+:(\w+).*?do\s*\|(\w+)\|(.+?)^\s*end/m).each do |table, var, block|
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(/create_table\s+:(\w+).*?do\s*\|(\w+)\|(.+?)^\s*end/m).each do |table, var, block|
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