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
@@ -28,8 +28,21 @@ module Woods
28
28
  include SharedDependencyScanner
29
29
  include RouteHelperResolver
30
30
 
31
+ # Matches everything between a `def foo_params` line and its
32
+ # params.require/permit or params.expect call, without crossing into
33
+ # a sibling method's body. Used by {#extract_permitted_params}.
34
+ PARAMS_METHOD_BODY = /(?:(?!\bdef\b)[\s\S])*?/
35
+
36
+ # @return [Array<String>] Warnings collected during extraction
37
+ # (concern-inlining fallbacks). Drained by the orchestrator, which
38
+ # collects warnings from every extractor that exposes them.
39
+ attr_reader :warnings
40
+
31
41
  def initialize
32
42
  @routes_map = build_routes_map
43
+ @concern_cache = {}
44
+ @concern_module_cache = {}
45
+ @warnings = []
33
46
  build_route_helper_map
34
47
  end
35
48
 
@@ -37,20 +50,56 @@ module Woods
37
50
  #
38
51
  # @return [Array<ExtractedUnit>] List of controller units
39
52
  def extract_all
40
- controllers = ApplicationController.descendants
41
-
42
- controllers = (controllers + ActionController::API.descendants).uniq if defined?(ActionController::API)
43
-
44
- controllers.map do |controller|
53
+ discoverable_classes.map do |controller|
45
54
  extract_controller(controller)
46
55
  end.compact
47
56
  end
48
57
 
58
+ # The controller classes this extractor would extract from the running
59
+ # app. Shared with the incremental path's class reconciliation (#164).
60
+ #
61
+ # Discovery walks +ActionController::Base.descendants+ and
62
+ # +ActionController::API.descendants+ — not
63
+ # +ApplicationController.descendants+, which excludes the receiver
64
+ # (Class#descendants never includes the class itself, so
65
+ # ApplicationController — usually the richest controller in the app —
66
+ # was never indexed) and misses controllers inheriting straight from
67
+ # +ActionController::Base+ (#200). Each base is guarded with
68
+ # +defined?+ so a host missing one, or both (no NameError on hosts
69
+ # without an ApplicationController constant), simply contributes
70
+ # nothing.
71
+ #
72
+ # Framework-internal descendants (Rails::InfoController,
73
+ # ActiveStorage controllers, engine controllers) share this ancestry
74
+ # but live in gem source; {#app_defined_controller?} keeps them out.
75
+ # That filter is shared with {#extract_controller} so this set stays
76
+ # exactly the set extract_controller accepts — it is the incremental
77
+ # reconciliation input, and any class listed here that
78
+ # extract_controller rejects would be recomputed as a phantom
79
+ # "addition" on every incremental run (same reasoning as
80
+ # ViewComponentExtractor's preview filtering).
81
+ #
82
+ # @return [Array<Class>]
83
+ def discoverable_classes
84
+ controllers = []
85
+ controllers.concat(ActionController::Base.descendants) if defined?(ActionController::Base)
86
+ controllers.concat(ActionController::API.descendants) if defined?(ActionController::API)
87
+ controllers.uniq.select { |controller| app_defined_controller?(controller) }
88
+ end
89
+
49
90
  # Extract a single controller
50
91
  #
92
+ # Rejects classes the extractor does not own — anonymous classes and
93
+ # framework-internal controllers — via {#app_defined_controller?},
94
+ # the same gate {#discoverable_classes} applies, so the two stay in
95
+ # agreement for incremental class reconciliation.
96
+ #
51
97
  # @param controller [Class] The controller class
52
- # @return [ExtractedUnit] The extracted unit
98
+ # @return [ExtractedUnit, nil] The extracted unit, or nil for classes
99
+ # the extractor rejects
53
100
  def extract_controller(controller)
101
+ return nil unless app_defined_controller?(controller)
102
+
54
103
  unit = ExtractedUnit.new(
55
104
  type: :controller,
56
105
  identifier: controller.name,
@@ -61,8 +110,9 @@ module Woods
61
110
  source = source_path && File.exist?(source_path) ? File.read(source_path) : ''
62
111
 
63
112
  unit.namespace = extract_namespace(controller)
64
- unit.source_code = build_composite_source(controller, source)
65
- unit.metadata = extract_metadata(controller, source)
113
+ inlined_source, inlined_concerns = build_controller_source_with_concerns(controller, source)
114
+ unit.source_code = build_composite_source(controller, inlined_source)
115
+ unit.metadata = extract_metadata(controller, source, inlined_concerns: inlined_concerns)
66
116
  unit.dependencies = extract_dependencies(controller, source)
67
117
 
68
118
  # Controllers benefit from per-action chunks
@@ -116,6 +166,32 @@ module Woods
116
166
  # Source Building
117
167
  # ──────────────────────────────────────────────────────────────────────
118
168
 
169
+ # Whether a controller class belongs to the host application.
170
+ #
171
+ # Discovery starts from the ActionController bases (#200), which
172
+ # framework controllers (Rails::InfoController, ActiveStorage's
173
+ # controllers, engine controllers) also descend from. App-defined
174
+ # means: the class is named, and it resolves to an existing source
175
+ # file that {SharedUtilityMethods#app_source?} accepts (under
176
+ # +Rails.root+, outside vendor/ and node_modules/). Framework classes
177
+ # resolve to gem paths, so {#source_file_for} falls back to a
178
+ # convention path that does not exist and they are rejected.
179
+ #
180
+ # Shared by {#discoverable_classes} and {#extract_controller}; the
181
+ # two must agree or the incremental class reconciliation recomputes
182
+ # the same phantom "additions" every run.
183
+ #
184
+ # @param controller [Class] Candidate controller class
185
+ # @return [Boolean]
186
+ def app_defined_controller?(controller)
187
+ return false if controller.name.nil?
188
+
189
+ path = source_file_for(controller)
190
+ return false unless path
191
+
192
+ File.exist?(path) && app_source?(path, Rails.root.to_s)
193
+ end
194
+
119
195
  # Find the source file for a controller, validating paths are within Rails.root.
120
196
  #
121
197
  # Convention path first, then introspection via {#resolve_source_location}
@@ -130,15 +206,13 @@ module Woods
130
206
  resolve_source_location(controller, app_root: Rails.root.to_s, fallback: convention_path)
131
207
  end
132
208
 
133
- # Build composite source with routes and filters as headers
134
- def build_composite_source(controller, source = nil)
135
- if source.nil?
136
- source_path = source_file_for(controller)
137
- return '' unless source_path && File.exist?(source_path)
138
-
139
- source = File.read(source_path)
140
- end
141
-
209
+ # Build composite source with routes and filters as headers.
210
+ #
211
+ # The sole caller ({#extract_controller}) always passes the
212
+ # already concern-inlined source (see
213
+ # {#build_controller_source_with_concerns}), which returns '' rather
214
+ # than nil for a missing file — so this never sees a nil source.
215
+ def build_composite_source(controller, source)
142
216
  # Prepend route information
143
217
  routes_comment = build_routes_comment(controller)
144
218
 
@@ -207,12 +281,222 @@ module Woods
207
281
  end
208
282
  end
209
283
 
284
+ # ──────────────────────────────────────────────────────────────────────
285
+ # Concern Detection & Inlining
286
+ # ──────────────────────────────────────────────────────────────────────
287
+
288
+ # Modules included in the controller that are application-defined
289
+ # concerns.
290
+ #
291
+ # Detection is by membership, not name (#175): Rails does not
292
+ # namespace controller concerns — app/controllers/concerns/
293
+ # requires_author.rb defines top-level +RequiresAuthor+ — so the old
294
+ # name-substring check ('Concern'/'Concerns') never matched idiomatic
295
+ # controller concerns and +included_concerns+ came back empty while
296
+ # the concern's effects (filters) were captured.
297
+ #
298
+ # @param controller [Class] The controller class
299
+ # @return [Array<Module>] App-defined concern modules
300
+ def detect_included_concerns(controller)
301
+ controller.included_modules.select { |mod| app_concern_module?(mod) }
302
+ end
303
+
304
+ # Whether a module included in a controller is an application-defined
305
+ # concern.
306
+ #
307
+ # A module counts when it extends +ActiveSupport::Concern+ (the
308
+ # idiomatic case) OR its resolved source file sits under an
309
+ # app/**/concerns directory (a plain module mixed in from concerns/
310
+ # without the extend). Framework modules are excluded first: many gem
311
+ # modules extend +ActiveSupport::Concern+ too
312
+ # (ActionController::MimeResponds et al.), so any module whose source
313
+ # resolves outside the app ({SharedUtilityMethods#app_source?}) is
314
+ # rejected before either positive check runs. Verdicts are memoized
315
+ # per module name — ApplicationController's includes recur in every
316
+ # controller of the app.
317
+ #
318
+ # @param mod [Module] A module from the controller's included_modules
319
+ # @return [Boolean]
320
+ def app_concern_module?(mod)
321
+ return false unless mod.name
322
+
323
+ return @concern_module_cache[mod.name] if @concern_module_cache.key?(mod.name)
324
+
325
+ @concern_module_cache[mod.name] = compute_app_concern_module(mod)
326
+ end
327
+
328
+ # Uncached concern verdict for a named module (see
329
+ # {#app_concern_module?} for the detection rules).
330
+ #
331
+ # @param mod [Module] A named module
332
+ # @return [Boolean]
333
+ def compute_app_concern_module(mod)
334
+ path = module_source_path(mod)
335
+ return false if path && !app_source?(path, Rails.root.to_s)
336
+
337
+ activesupport_concern?(mod) || concerns_directory_path?(path)
338
+ end
339
+
340
+ # Resolve a module's defining source file via const_source_location.
341
+ #
342
+ # @param mod [Module] A named module
343
+ # @return [String, nil] Absolute path, or nil when unresolvable
344
+ def module_source_path(mod)
345
+ return nil unless Object.respond_to?(:const_source_location)
346
+
347
+ Object.const_source_location(mod.name)&.first
348
+ rescue StandardError
349
+ nil
350
+ end
351
+
352
+ # Whether a module extends ActiveSupport::Concern (the idiomatic
353
+ # concern marker). Membership lives on the singleton class.
354
+ #
355
+ # @param mod [Module] A module to test
356
+ # @return [Boolean]
357
+ def activesupport_concern?(mod)
358
+ return false unless defined?(ActiveSupport::Concern)
359
+
360
+ mod.singleton_class.include?(ActiveSupport::Concern)
361
+ end
362
+
363
+ # Whether a resolved source path sits under an app/**/concerns
364
+ # directory (e.g. app/controllers/concerns/, app/models/concerns/).
365
+ # Callers guarantee the path, when present, is app source.
366
+ #
367
+ # @param path [String, nil] Absolute path under Rails.root, or nil
368
+ # @return [Boolean]
369
+ def concerns_directory_path?(path)
370
+ return false unless path
371
+
372
+ relative = path.delete_prefix("#{Rails.root}/")
373
+ relative.match?(%r{\Aapp/(?:[^/]+/)*concerns/})
374
+ end
375
+
376
+ # Read controller source and inline all detected concerns, mirroring
377
+ # ModelExtractor's approach: concern code is inserted as '#'-prefixed
378
+ # comment lines right after the class declaration (nested or compact
379
+ # style), falling back to appending at end-of-source with a warning
380
+ # rather than silently dropping the block.
381
+ #
382
+ # An empty source (missing file) is returned untouched — inlining
383
+ # into nothing would fabricate source_code out of comments.
384
+ #
385
+ # @param controller [Class] The controller class
386
+ # @param source [String, nil] Pre-read controller source (read from
387
+ # disk when nil)
388
+ # @return [Array(String, Array<String>)] The concern-inlined source
389
+ # and the demodulized names of the concerns actually inlined into
390
+ # it. Returning the pair keeps metadata[:inlined_concerns] truthful
391
+ # — it is derived from the insertion result, never recomputed
392
+ # independently of what the composite source carries.
393
+ def build_controller_source_with_concerns(controller, source = nil)
394
+ if source.nil?
395
+ source_path = source_file_for(controller)
396
+ return ['', []] unless source_path && File.exist?(source_path)
397
+
398
+ source = File.read(source_path)
399
+ end
400
+
401
+ return [source, []] if source.empty?
402
+
403
+ concern_sources = resolved_concern_sources(controller)
404
+ return [source, []] if concern_sources.empty?
405
+
406
+ [insert_concern_block(controller, source, build_concern_block(concern_sources)),
407
+ concern_sources.map { |name, _code| name.demodulize }]
408
+ end
409
+
410
+ # Resolve [name, code] pairs for every detected concern whose source
411
+ # file can be located.
412
+ #
413
+ # @param controller [Class] The controller class
414
+ # @return [Array<Array(String, String)>]
415
+ def resolved_concern_sources(controller)
416
+ detect_included_concerns(controller).filter_map { |mod| concern_source(mod) }
417
+ end
418
+
419
+ # Get the source code for a concern, with caching. Resolution reuses
420
+ # {#module_source_path} — the same location detection validated — so
421
+ # a concern detected purely by ActiveSupport::Concern membership with
422
+ # no resolvable file keeps its edge and metadata entry but is not
423
+ # inlined.
424
+ #
425
+ # @param mod [Module] A detected concern module
426
+ # @return [Array(String, String), nil] [name, code] or nil
427
+ def concern_source(mod)
428
+ return @concern_cache[mod.name] if @concern_cache.key?(mod.name)
429
+
430
+ path = module_source_path(mod)
431
+ return nil unless path && File.exist?(path)
432
+
433
+ @concern_cache[mod.name] = [mod.name, File.read(path)]
434
+ end
435
+
436
+ # Render concern sources as a '#'-commented display block showing
437
+ # what's mixed in (same display form as ModelExtractor).
438
+ #
439
+ # @param concern_sources [Array<Array(String, String)>] [name, code] pairs
440
+ # @return [String]
441
+ def build_concern_block(concern_sources)
442
+ concern_sources.map do |name, code|
443
+ indented = code.lines.map { |l| " # #{l.rstrip}" }.join("\n")
444
+ <<~CONCERN
445
+ # ┌─────────────────────────────────────────────────────────────────────┐
446
+ # │ Included from: #{name.ljust(54)}│
447
+ # └─────────────────────────────────────────────────────────────────────┘
448
+ #{indented}
449
+ # ─────────────────────────── End #{name} ───────────────────────────
450
+ CONCERN
451
+ end.join("\n\n")
452
+ end
453
+
454
+ # Insert the concern block after the controller's class declaration
455
+ # line. Falls back to appending at end-of-source (recording a
456
+ # warning) when no declaration matches — dropping the block silently
457
+ # would leave source_code contradicting metadata[:inlined_concerns].
458
+ #
459
+ # @param controller [Class] The controller class
460
+ # @param source [String] The controller source
461
+ # @param concern_block [String] Commented concern block to insert
462
+ # @return [String]
463
+ def insert_concern_block(controller, source, concern_block)
464
+ pattern = class_declaration_pattern(controller)
465
+ return source.sub(pattern) { "#{::Regexp.last_match(1)}\n\n#{concern_block}" } if source.match?(pattern)
466
+
467
+ @warnings << "[#{controller.name}] No class declaration matched for concern inlining; " \
468
+ 'appending inlined concern block at end of source'
469
+ "#{source.chomp}\n\n#{concern_block}"
470
+ end
471
+
472
+ # Regexp matching the controller's class declaration in either nested
473
+ # (+class PostsController+) or compact
474
+ # (+class Admin::PostsController+) style. The word boundary stops
475
+ # +class PostsControllerError+ from claiming the block; the +(?!::)+
476
+ # lookahead stops a nested +class PostsController::Error+ from
477
+ # claiming it either.
478
+ #
479
+ # @param controller [Class] The controller class
480
+ # @return [Regexp]
481
+ def class_declaration_pattern(controller)
482
+ /(class\s+(?:[\w:]+::)?#{Regexp.escape(controller.name.demodulize)}\b(?!::).*$)/
483
+ end
484
+
210
485
  # ──────────────────────────────────────────────────────────────────────
211
486
  # Metadata Extraction
212
487
  # ──────────────────────────────────────────────────────────────────────
213
488
 
214
489
  # Extract comprehensive metadata
215
- def extract_metadata(controller, source = nil)
490
+ #
491
+ # @param controller [Class] The controller class
492
+ # @param source [String, nil] The raw controller source code
493
+ # @param inlined_concerns [Array<String>, nil] Demodulized names of
494
+ # the concerns actually inlined into the unit's source_code (from
495
+ # {#build_controller_source_with_concerns}). When nil, derived by
496
+ # running the same inlining — the metadata must never claim a
497
+ # concern the composite source does not carry.
498
+ # @return [Hash]
499
+ def extract_metadata(controller, source = nil, inlined_concerns: nil)
216
500
  own_methods = controller.instance_methods(false).to_set(&:to_s)
217
501
  actions = controller.action_methods.select { |m| own_methods.include?(m) }.to_a
218
502
 
@@ -231,9 +515,12 @@ module Woods
231
515
  .map(&:name)
232
516
  .compact,
233
517
 
234
- # Concerns included
518
+ # Concerns included (detected by membership, not name — #175)
235
519
  included_concerns: extract_included_concerns(controller),
236
520
 
521
+ # Concerns actually inlined into source_code (demodulized)
522
+ inlined_concerns: inlined_concerns || build_controller_source_with_concerns(controller, source).last,
523
+
237
524
  # Response formats
238
525
  responds_to: extract_respond_formats(controller, source),
239
526
 
@@ -246,10 +533,13 @@ module Woods
246
533
  }
247
534
  end
248
535
 
536
+ # Names of the app-defined concerns included in the controller.
537
+ # Detection lives in {#detect_included_concerns} (#175).
538
+ #
539
+ # @param controller [Class] The controller class
540
+ # @return [Array<String>] Full module names
249
541
  def extract_included_concerns(controller)
250
- controller.included_modules
251
- .select { |m| m.name&.include?('Concern') || m.name&.include?('Concerns') }
252
- .map(&:name)
542
+ detect_included_concerns(controller).map(&:name)
253
543
  end
254
544
 
255
545
  def extract_respond_formats(controller, source = nil)
@@ -270,6 +560,18 @@ module Woods
270
560
  formats.uniq
271
561
  end
272
562
 
563
+ # Strong-parameter declarations, keyed by the `*_params` method name.
564
+ #
565
+ # Each entry is `{ model:, permitted: }`. `permitted` is the list of
566
+ # keys named at the TOP level of the `permit`/`expect` call — scalar
567
+ # symbols and hash keys alike — and never the members of a nested list
568
+ # or hash: `permit(:title, tags: [], meta: {seo: [:keyword]})` yields
569
+ # `%w[title tags meta]`. See {#permitted_keys}.
570
+ #
571
+ # @param controller [Class, nil] Controller class (used only to locate
572
+ # the source when +source+ is not supplied)
573
+ # @param source [String, nil] Controller source code
574
+ # @return [Hash{String => Hash}] `{ method_name => { model:, permitted: } }`
273
575
  def extract_permitted_params(controller, source = nil)
274
576
  if source.nil?
275
577
  source_path = source_file_for(controller)
@@ -280,23 +582,81 @@ module Woods
280
582
 
281
583
  params = {}
282
584
 
283
- # Match params.require(:x).permit(...) patterns
284
- source.scan(/def\s+(\w+_params).*?params\.require\(:(\w+)\)\.permit\((.*?)\)/m) do |method, model, permitted|
285
- params[method] = {
286
- model: model,
287
- permitted: permitted.scan(/:(\w+)/).flatten
288
- }
585
+ # Match params.require(:x).permit(...) patterns. The body segment
586
+ # between the method name and the require/permit call is bounded by
587
+ # a negative lookahead on `def` so a method with no permit call
588
+ # (e.g. `filter_params; params.fetch(:f); end`) never lets the scan
589
+ # run into the *next* method's body and misattribute its params.
590
+ # The /m flag plus a [\s\S] capture let the permit list cross
591
+ # newlines — the common style in large controllers (M2); without
592
+ # them `permit(` followed by a newline matched nothing and
593
+ # permitted_params came back empty. The `\s*` around each chain joint
594
+ # covers the fluent style the M2 fix missed (EXTA-5): the list could
595
+ # cross newlines but the call chain itself still could not, so
596
+ # `params.require(:post)\n .permit(...)` matched nothing.
597
+ source.scan(
598
+ /def\s+(\w+_params)\b#{PARAMS_METHOD_BODY}
599
+ params\s*\.\s*require\(:(\w+)\)\s*\.\s*permit\(([\s\S]*?)\)/xm
600
+ ) do |method, model, permitted|
601
+ params[method] = { model: model, permitted: permitted_keys(permitted) }
602
+ end
603
+
604
+ # Rails 8's params.expect(post: [:title, :body]) replacement for
605
+ # require(...).permit(...). Same multi-line capture as above (M2).
606
+ source.scan(
607
+ /def\s+(\w+_params)\b#{PARAMS_METHOD_BODY}
608
+ params\s*\.\s*expect\(\s*(\w+):\s*\[([\s\S]*?)\]\s*\)/xm
609
+ ) do |method, model, permitted|
610
+ params[method] ||= { model: model, permitted: permitted_keys(permitted) }
289
611
  end
290
612
 
291
613
  params
292
614
  end
293
615
 
616
+ # The top-level keys of a permit/expect argument list.
617
+ #
618
+ # The contract is deliberately flat and shallow: every key the caller
619
+ # names at the top level, whether declared as a scalar symbol
620
+ # (`:title`) or as a hash key (`tags: []`, `meta: {…}`), and nothing
621
+ # from inside a nested list or hash. Scanning for `:(\w+)` alone
622
+ # produced neither contract — hash keys, which is how every array and
623
+ # nested param is declared, were dropped while their nested leaves
624
+ # leaked in flat, so `permit(:title, tags: [], meta: {seo: [:keyword]})`
625
+ # reported `title, keyword` (EXTA-12).
626
+ #
627
+ # @param list [String] Raw text between `permit(`/`expect(…[` and its close
628
+ # @return [Array<String>] Top-level key names, in source order
629
+ def permitted_keys(list)
630
+ top_level_arguments(list).scan(/:(\w+)|(\w+):/).map { |symbol, key| symbol || key }.uniq
631
+ end
632
+
633
+ # Drop everything inside nested `[...]` / `{...}` groups, leaving the
634
+ # argument list's own tokens.
635
+ #
636
+ # @param list [String]
637
+ # @return [String]
638
+ def top_level_arguments(list)
639
+ depth = 0
640
+ list.each_char.with_object(+'') do |char, kept|
641
+ case char
642
+ when '[', '{' then depth += 1
643
+ when ']', '}' then depth -= 1 if depth.positive?
644
+ else kept << char if depth.zero?
645
+ end
646
+ end
647
+ end
648
+
294
649
  # ──────────────────────────────────────────────────────────────────────
295
650
  # Dependency Extraction
296
651
  # ──────────────────────────────────────────────────────────────────────
297
652
 
298
653
  def extract_dependencies(controller, source = nil)
299
- deps = []
654
+ # Included concerns add per-request behavior (filters, helpers).
655
+ # Same edge shape as ModelExtractor's concern edges so graph
656
+ # consumers see one format (#175).
657
+ deps = detect_included_concerns(controller).map do |mod|
658
+ { type: :concern, target: mod.name, via: :include }
659
+ end
300
660
 
301
661
  if source.nil?
302
662
  source_path = source_file_for(controller)
@@ -97,23 +97,16 @@ module Woods
97
97
  # @param source [String] Ruby source code
98
98
  # @return [String, nil] The class name or nil
99
99
  def extract_class_name(file_path, source)
100
- namespaces = source.scan(/^\s*module\s+([\w:]+)/).flatten
101
- class_match = source.match(/^\s*class\s+([\w:]+)/)
102
-
103
- if class_match
104
- base_class = class_match[1]
105
- if namespaces.any? && !base_class.include?('::')
106
- "#{namespaces.join('::')}::#{base_class}"
107
- else
108
- base_class
109
- end
110
- else
111
- relative = file_path.sub("#{Rails.root}/", '')
112
- relative
100
+ # Zeitwerk-governed naming (G-1) first, then position-aware
101
+ # (SourceNesting, #174): only modules still open at the class
102
+ # declaration qualify it. A sibling module that closed earlier, or a
103
+ # helper module nested inside the class, does not.
104
+ governed_class_name(file_path, source) || qualified_first_class_name(source) ||
105
+ file_path
106
+ .sub("#{Rails.root}/", '')
113
107
  .sub(%r{^app/(decorators|presenters|form_objects)/}, '')
114
108
  .sub('.rb', '')
115
109
  .camelize
116
- end
117
110
  end
118
111
 
119
112
  # ──────────────────────────────────────────────────────────────────────
@@ -46,16 +46,40 @@ module Woods
46
46
  Rails.application.respond_to?(:routes)
47
47
  end
48
48
 
49
- # Retrieve Engine subclasses, compatible with Ruby 3.0+.
50
- # Class#subclasses was added in Ruby 3.1; fall back to descendants filtering.
49
+ # Retrieve Engine subclasses.
50
+ #
51
+ # `Class#subclasses` is available on every supported Rails version
52
+ # (native since Ruby 3.1, backported by ActiveSupport's
53
+ # `core_ext/class/subclasses` before that, which every booted Rails
54
+ # app loads) — the ObjectSpace fallback below is defensive for the
55
+ # unlikely case that core_ext hasn't loaded, not a Ruby-version gap.
56
+ #
57
+ # Excludes {#host_application_class?} — Rails::Application is itself a
58
+ # Rails::Engine subclass (and the ObjectSpace fallback also reaches the
59
+ # host app's own concrete Application class), neither of which is an
60
+ # installed engine.
51
61
  #
52
62
  # @return [Array<Class>]
53
63
  def engine_subclasses
54
- if Rails::Engine.respond_to?(:subclasses)
55
- Rails::Engine.subclasses
56
- else
57
- ObjectSpace.each_object(Class).select { |klass| klass < Rails::Engine }
58
- end
64
+ classes = if Rails::Engine.respond_to?(:subclasses)
65
+ Rails::Engine.subclasses
66
+ else
67
+ ObjectSpace.each_object(Class).select { |klass| klass < Rails::Engine }
68
+ end
69
+ classes.reject { |klass| host_application_class?(klass) }
70
+ end
71
+
72
+ # Whether a class is Rails::Application itself, or the host app's own
73
+ # concrete subclass of it (e.g. MyApp::Application). Both descend from
74
+ # Rails::Engine but represent the host app, not an installed engine —
75
+ # including them produced a phantom "engine" unit for the app itself.
76
+ #
77
+ # @param klass [Class]
78
+ # @return [Boolean]
79
+ def host_application_class?(klass)
80
+ return false unless klass.is_a?(Module)
81
+
82
+ defined?(Rails::Application) && klass <= Rails::Application
59
83
  end
60
84
 
61
85
  # Build a mapping from engine class to mounted path by scanning app routes.
@@ -64,7 +88,7 @@ module Woods
64
88
  def build_mount_map
65
89
  map = {}
66
90
  Rails.application.routes.routes.each do |route|
67
- app = route.app
91
+ app = unwrap_mounted_app(route.app)
68
92
  next unless engine_class?(app)
69
93
 
70
94
  path = extract_mount_path(route)
@@ -77,6 +101,27 @@ module Woods
77
101
  {}
78
102
  end
79
103
 
104
+ # Unwrap a mounted route's app down to the actual engine class.
105
+ #
106
+ # For a mounted engine, `route.app` is an
107
+ # `ActionDispatch::Routing::Mapper::Constraints` wrapper, not the
108
+ # engine itself — the engine is `route.app.app`. {#engine_class?}
109
+ # never matched the wrapper, so `mounted_path` was always nil in a
110
+ # live app. Bounded to a handful of unwraps rather than an open-ended
111
+ # loop, since a route app is never nested more than a level or two
112
+ # deep in practice.
113
+ #
114
+ # @param app [Object] The route's `.app` value
115
+ # @return [Object] The unwrapped app, or the original value unchanged
116
+ def unwrap_mounted_app(app)
117
+ 5.times do
118
+ break if engine_class?(app) || !app.respond_to?(:app)
119
+
120
+ app = app.app
121
+ end
122
+ app
123
+ end
124
+
80
125
  # Check if an object is a Rails::Engine subclass.
81
126
  #
82
127
  # Uses duck-typing: checks for engine_name method which is defined on all