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
@@ -48,12 +48,22 @@ module Woods
48
48
  #
49
49
  # @return [Array<ExtractedUnit>] List of model units
50
50
  def extract_all
51
+ discoverable_classes.map { |model| extract_model(model) }.compact
52
+ end
53
+
54
+ # The model classes this extractor would extract from the running app.
55
+ #
56
+ # Shared with the incremental path, which reconciles this set against
57
+ # the identifiers already in the dependency graph to spot classes added
58
+ # since the last extraction (#164). Keeping discovery in one method is
59
+ # what makes that reconciliation exact rather than a re-guess.
60
+ #
61
+ # @return [Array<Class>]
62
+ def discoverable_classes
51
63
  ActiveRecord::Base.descendants
52
- .reject(&:abstract_class?)
64
+ .reject { |m| abstract_class?(m) }
53
65
  .reject { |m| m.name.nil? } # Skip anonymous classes
54
66
  .reject { |m| habtm_join_model?(m) }
55
- .map { |model| extract_model(model) }
56
- .compact
57
67
  end
58
68
 
59
69
  # Extract a single model
@@ -71,12 +81,15 @@ module Woods
71
81
  source = source_path && File.exist?(source_path) ? File.read(source_path) : nil
72
82
 
73
83
  unit.namespace = model.module_parent.name unless model.module_parent == Object
74
- unit.source_code = build_composite_source(model, source)
75
- unit.metadata = extract_metadata(model, source)
84
+ inlined_source, inlined_concerns = build_model_source_with_concerns(model, source)
85
+ unit.source_code = build_composite_source(model, inlined_source)
86
+ unit.metadata = extract_metadata(model, source, inlined_concerns: inlined_concerns)
76
87
  unit.dependencies = extract_dependencies(model, source)
77
88
 
78
- # Enrich callbacks with side-effect analysis
79
- enrich_callbacks_with_side_effects(unit, source)
89
+ # Enrich callbacks with side-effect analysis. The analyzer parses real
90
+ # Ruby, so it gets the raw analysis composite — the display composite
91
+ # comments concern bodies out, which Prism cannot see.
92
+ enrich_callbacks_with_side_effects(unit, source, analysis_source: build_analysis_source(model, source))
80
93
 
81
94
  # Build semantic chunks for all models (summary, associations, callbacks, validations)
82
95
  unit.chunks = build_chunks(unit)
@@ -90,11 +103,28 @@ module Woods
90
103
 
91
104
  private
92
105
 
106
+ # Is this an abstract AR base class? Guarded exactly like
107
+ # {Woods::ModelNameCache#abstract_model?}: a descendant that raises on
108
+ # the call (a half-loaded class under the NameError fallback) is kept
109
+ # rather than dropped. An unguarded `.reject(&:abstract_class?)` here
110
+ # let that raise escape {#discoverable_classes} and abort the whole
111
+ # models phase (L1); per-class extraction failures are already handled
112
+ # by {#extract_model}'s own rescue.
113
+ #
114
+ # @param klass [Class]
115
+ # @return [Boolean] true when the class is an abstract AR base class
116
+ def abstract_class?(klass)
117
+ klass.respond_to?(:abstract_class?) && klass.abstract_class?
118
+ rescue StandardError
119
+ false
120
+ end
121
+
93
122
  # Find the source file for a model, handling STI and namespacing.
94
123
  #
95
124
  # Uses convention path first (most reliable for models), then falls
96
- # back to introspection via {#resolve_source_location} which filters
97
- # out vendor/node_modules paths.
125
+ # back to introspection via {#resolve_source_location}, which prefers
126
+ # app-owned locations and keeps a gem-owned class (an engine model) at
127
+ # its real gem path rather than a synthesized app path.
98
128
  def source_file_for(model)
99
129
  convention_path = Rails.root.join("app/models/#{model.name.underscore}.rb").to_s
100
130
  return convention_path if File.exist?(convention_path)
@@ -110,17 +140,15 @@ module Woods
110
140
  model.name.demodulize.start_with?('HABTM_')
111
141
  end
112
142
 
113
- # Build composite source with schema header and inlined concerns
114
- def build_composite_source(model, source = nil)
115
- parts = []
116
-
117
- # Schema information as a header comment
118
- parts << build_schema_comment(model)
119
-
120
- # Main model source with concerns inlined
121
- parts << build_model_source_with_concerns(model, source)
122
-
123
- parts.compact.join("\n\n")
143
+ # Build composite source with schema header and the concern-inlined
144
+ # model source.
145
+ #
146
+ # @param model [Class] The ActiveRecord model class
147
+ # @param inlined_source [String] Model source with concerns already
148
+ # inlined (see {#build_model_source_with_concerns})
149
+ # @return [String]
150
+ def build_composite_source(model, inlined_source)
151
+ [build_schema_comment(model), inlined_source].compact.join("\n\n")
124
152
  end
125
153
 
126
154
  # Generate schema comment block with columns, indexes, and foreign keys
@@ -181,39 +209,93 @@ module Woods
181
209
  []
182
210
  end
183
211
 
184
- # Read model source and inline all included concerns
212
+ # Read model source and inline all included concerns.
213
+ #
214
+ # Concern code is inserted as '# '-prefixed comment lines right after
215
+ # the model's class declaration, matching both nested (+class Book+)
216
+ # and compact (+class Library::Book+) declaration styles. When no
217
+ # declaration line can be found in unconventional source, the block is
218
+ # appended at the end instead of being silently discarded, and a
219
+ # warning naming the model is recorded.
220
+ #
221
+ # @param model [Class] The ActiveRecord model class
222
+ # @param source [String, nil] Pre-read model source (read from disk when nil)
223
+ # @return [Array(String, Array<String>)] The concern-inlined source and
224
+ # the demodulized names of the concerns actually inlined into it.
225
+ # Returning the pair keeps metadata[:inlined_concerns] truthful —
226
+ # it is derived from the insertion result, never recomputed
227
+ # independently of what the composite source carries.
185
228
  def build_model_source_with_concerns(model, source = nil)
186
229
  if source.nil?
187
230
  source_path = source_file_for(model)
188
- return '' unless source_path && File.exist?(source_path)
231
+ return ['', []] unless source_path && File.exist?(source_path)
189
232
 
190
233
  source = File.read(source_path)
191
234
  end
192
235
 
193
- # Find all included concerns and inline them
194
- included_modules = extract_included_modules(model)
195
- concern_sources = included_modules.filter_map { |mod| concern_source(mod) }
196
-
197
- if concern_sources.any?
198
- # Insert concern code as comments showing what's mixed in
199
- concern_block = concern_sources.map do |name, code|
200
- indented = code.lines.map { |l| " # #{l.rstrip}" }.join("\n")
201
- <<~CONCERN
202
- # ┌─────────────────────────────────────────────────────────────────────┐
203
- # Included from: #{name.ljust(54)}│
204
- # └─────────────────────────────────────────────────────────────────────┘
205
- #{indented}
206
- # ─────────────────────────── End #{name} ───────────────────────────
207
- CONCERN
208
- end.join("\n\n")
209
-
210
- # Insert after class declaration line
211
- source.sub(/(class\s+#{Regexp.escape(model.name.demodulize)}.*$)/) do
212
- "#{::Regexp.last_match(1)}\n\n#{concern_block}"
213
- end
214
- else
215
- source
216
- end
236
+ concern_sources = resolved_concern_sources(model)
237
+ return [source, []] if concern_sources.empty?
238
+
239
+ [insert_concern_block(model, source, build_concern_block(concern_sources)),
240
+ concern_sources.map { |name, _code| name.demodulize }]
241
+ end
242
+
243
+ # Resolve [name, code] pairs for every concern included in the model
244
+ # whose source file can be located.
245
+ #
246
+ # @param model [Class] The ActiveRecord model class
247
+ # @return [Array<Array(String, String)>]
248
+ def resolved_concern_sources(model)
249
+ extract_included_modules(model).filter_map { |mod| concern_source(mod) }
250
+ end
251
+
252
+ # Render concern sources as a '#'-commented display block showing
253
+ # what's mixed in.
254
+ #
255
+ # @param concern_sources [Array<Array(String, String)>] [name, code] pairs
256
+ # @return [String]
257
+ def build_concern_block(concern_sources)
258
+ concern_sources.map do |name, code|
259
+ indented = code.lines.map { |l| " # #{l.rstrip}" }.join("\n")
260
+ <<~CONCERN
261
+ # ┌─────────────────────────────────────────────────────────────────────┐
262
+ # │ Included from: #{name.ljust(54)}│
263
+ # └─────────────────────────────────────────────────────────────────────┘
264
+ #{indented}
265
+ # ─────────────────────────── End #{name} ───────────────────────────
266
+ CONCERN
267
+ end.join("\n\n")
268
+ end
269
+
270
+ # Insert the concern block after the model's class declaration line.
271
+ #
272
+ # Falls back to appending at end-of-source (recording a warning) when
273
+ # no declaration matches — dropping the block silently would leave
274
+ # source_code contradicting metadata[:inlined_concerns].
275
+ #
276
+ # @param model [Class] The ActiveRecord model class
277
+ # @param source [String] The model source
278
+ # @param concern_block [String] Commented concern block to insert
279
+ # @return [String]
280
+ def insert_concern_block(model, source, concern_block)
281
+ pattern = class_declaration_pattern(model)
282
+ return source.sub(pattern) { "#{::Regexp.last_match(1)}\n\n#{concern_block}" } if source.match?(pattern)
283
+
284
+ @warnings << "[#{model.name}] No class declaration matched for concern inlining; " \
285
+ 'appending inlined concern block at end of source'
286
+ "#{source.chomp}\n\n#{concern_block}"
287
+ end
288
+
289
+ # Regexp matching the model's class declaration in either nested
290
+ # (+class Book+) or compact (+class Library::Book+) style. The word
291
+ # boundary stops +class BookmarkError+ from claiming model Book's
292
+ # concern block; the +(?!::)+ lookahead stops a nested
293
+ # +class Book::Error+ from claiming it either.
294
+ #
295
+ # @param model [Class] The ActiveRecord model class
296
+ # @return [Regexp]
297
+ def class_declaration_pattern(model)
298
+ /(class\s+(?:[\w:]+::)?#{Regexp.escape(model.name.demodulize)}\b(?!::).*$)/
217
299
  end
218
300
 
219
301
  # Get modules included specifically in this model (not inherited)
@@ -315,11 +397,21 @@ module Woods
315
397
  # ──────────────────────────────────────────────────────────────────────
316
398
 
317
399
  # Extract comprehensive metadata for retrieval and filtering
318
- def extract_metadata(model, source = nil)
400
+ #
401
+ # @param model [Class] The ActiveRecord model class
402
+ # @param source [String, nil] The model source code
403
+ # @param inlined_concerns [Array<String>, nil] Demodulized names of the
404
+ # concerns actually inlined into the unit's source_code (from
405
+ # {#build_model_source_with_concerns}). When nil, derived by running
406
+ # the same inlining — the metadata must never claim a concern the
407
+ # composite source does not carry.
408
+ # @return [Hash]
409
+ def extract_metadata(model, source = nil, inlined_concerns: nil)
319
410
  {
320
411
  # Core identifiers
321
412
  table_name: model.table_name,
322
413
  primary_key: model.primary_key,
414
+ database: database_name_for(model),
323
415
 
324
416
  # Relationships and behaviors
325
417
  associations: extract_associations(model),
@@ -327,9 +419,7 @@ module Woods
327
419
  callbacks: extract_callbacks(model),
328
420
  scopes: extract_scopes(model, source),
329
421
  enums: extract_enums(model),
330
- inlined_concerns: extract_included_modules(model)
331
- .select { |mod| mod.name && concern_source(mod) }
332
- .map { |mod| mod.name.demodulize },
422
+ inlined_concerns: inlined_concerns || build_model_source_with_concerns(model, source).last,
333
423
 
334
424
  # API surface
335
425
  class_methods: model.methods(false).sort,
@@ -359,6 +449,11 @@ module Woods
359
449
  []
360
450
  end,
361
451
 
452
+ # Foreign keys as the connection reports them. The target table's
453
+ # database is a graph-level concern, resolved by the
454
+ # cross_database_edges report, not stored per model here (#280).
455
+ foreign_keys: extract_foreign_keys(model),
456
+
362
457
  # ActiveStorage / ActionText
363
458
  active_storage_attachments: extract_active_storage_attachments(source),
364
459
  action_text_fields: extract_action_text_fields(source),
@@ -464,7 +559,15 @@ module Woods
464
559
  # Extract all associations with full details.
465
560
  # Broken associations (e.g. missing class_name) are skipped with a warning
466
561
  # instead of aborting the entire model extraction.
562
+ #
563
+ # `from_db` and `to_db` name the database each side resolves to (nil on
564
+ # Rails 6.0, where `connection_db_config` does not exist, and nil on
565
+ # the target side for a polymorphic or unresolvable class). `through_db`
566
+ # names the database a has_many :through join model resolves to (#280),
567
+ # nil for a plain association.
568
+ # `disable_joins` is the Rails 7.0+ option read from the reflection.
467
569
  def extract_associations(model)
570
+ from_db = database_name_for(model)
468
571
  model.reflect_on_all_associations.filter_map do |assoc|
469
572
  {
470
573
  name: assoc.name,
@@ -472,9 +575,13 @@ module Woods
472
575
  target: assoc.class_name,
473
576
  options: extract_association_options(assoc),
474
577
  through: assoc.options[:through],
578
+ through_db: through_association_database(assoc),
475
579
  polymorphic: assoc.polymorphic?,
476
580
  foreign_key: assoc.foreign_key,
477
- inverse_of: assoc.inverse_of&.name
581
+ inverse_of: assoc.inverse_of&.name,
582
+ from_db: from_db,
583
+ to_db: association_target_database(assoc),
584
+ disable_joins: assoc.options[:disable_joins] == true
478
585
  }
479
586
  rescue NameError => e
480
587
  @warnings << "[#{model.name}] Skipping broken association #{assoc.name}: #{e.message}"
@@ -487,7 +594,7 @@ module Woods
487
594
  :dependent, :through, :source, :source_type,
488
595
  :foreign_key, :primary_key, :inverse_of,
489
596
  :counter_cache, :touch, :optional, :required,
490
- :class_name, :as, :foreign_type
597
+ :class_name, :as, :foreign_type, :disable_joins
491
598
  )
492
599
  end
493
600
 
@@ -615,11 +722,34 @@ module Woods
615
722
  # Dependency Extraction
616
723
  # ──────────────────────────────────────────────────────────────────────
617
724
 
618
- # Extract what this model depends on
725
+ # Extract what this model depends on.
726
+ #
727
+ # Association reflections normally become a +:model+ edge labeled with
728
+ # the association macro. Polymorphic associations are the exception:
729
+ # `belongs_to :commentable, polymorphic: true` names an *interface*,
730
+ # not a model — +AssociationReflection#class_name+ camelizes the
731
+ # association name ("Commentable") without constantizing it, so the
732
+ # NameError rescue never fires and the edge pointed at a nonexistent
733
+ # node, or worse, at an unrelated real constant (an app's Commentable
734
+ # concern) mislabeled as a +:belongs_to+ model edge (#199). Those
735
+ # edges now carry +via: :polymorphic_interface+ instead of the macro:
736
+ # the interface name stays visible in the graph but is distinguishable
737
+ # from a resolvable model reference.
619
738
  def extract_dependencies(model, source = nil)
620
- # Associations point to other models
739
+ # Associations point to other models. `through`, `through_db`, and
740
+ # `disable_joins` ride on the edge so the graph can report a
741
+ # has_many :through that crosses databases without disable_joins,
742
+ # or whose join model itself sits on a third database (#280).
621
743
  deps = model.reflect_on_all_associations.filter_map do |assoc|
622
- { type: :model, target: assoc.class_name, via: assoc.macro }
744
+ via = polymorphic_reflection?(assoc) ? :polymorphic_interface : assoc.macro
745
+ dep = { type: :model, target: assoc.class_name, via: via }
746
+ if assoc.options[:through]
747
+ dep[:through] = assoc.options[:through].to_s
748
+ through_db = through_association_database(assoc)
749
+ dep[:through_db] = through_db if through_db
750
+ end
751
+ dep[:disable_joins] = true if assoc.options[:disable_joins] == true
752
+ dep
623
753
  rescue NameError => e
624
754
  @warnings << "[#{model.name}] Skipping broken association dep #{assoc.name}: #{e.message}"
625
755
  nil
@@ -657,18 +787,123 @@ module Woods
657
787
  consolidate_dependencies(deps)
658
788
  end
659
789
 
790
+ # Whether an association reflection declares a polymorphic interface.
791
+ #
792
+ # Guarded with +respond_to?+ because not every reflection type across
793
+ # Rails versions exposes +#polymorphic?+ — a reflection without it is
794
+ # treated as a plain (non-polymorphic) association.
795
+ #
796
+ # @param assoc [ActiveRecord::Reflection::AbstractReflection]
797
+ # @return [Boolean]
798
+ def polymorphic_reflection?(assoc)
799
+ assoc.respond_to?(:polymorphic?) && assoc.polymorphic?
800
+ end
801
+
802
+ # The database a class resolves to, by runtime reflection.
803
+ #
804
+ # `connection_db_config` (Rails 6.1+) climbs to the abstract class that
805
+ # declared `connects_to`, so a concrete model that inherits its
806
+ # connection answers with the inherited database. This is what a
807
+ # regex over `connects_to database:` in the source could never see.
808
+ # Rails 6.0 has no `connection_db_config`; the answer degrades to nil.
809
+ #
810
+ # @param klass [Class]
811
+ # @return [String, nil]
812
+ def database_name_for(klass)
813
+ return nil unless klass.respond_to?(:connection_db_config)
814
+
815
+ config = klass.connection_db_config
816
+ config.respond_to?(:name) ? config.name.to_s : nil
817
+ rescue StandardError
818
+ nil
819
+ end
820
+
821
+ # The database an association's target resolves to. Nil for a
822
+ # polymorphic interface (no single class) and for a target that does
823
+ # not constantize.
824
+ #
825
+ # @param assoc [ActiveRecord::Reflection::AbstractReflection]
826
+ # @return [String, nil]
827
+ def association_target_database(assoc)
828
+ return nil if polymorphic_reflection?(assoc)
829
+
830
+ database_name_for(assoc.klass)
831
+ rescue StandardError
832
+ nil
833
+ end
834
+
835
+ # The database a has_many :through association's join model resolves
836
+ # to. Nil for a plain association, when the through reflection cannot
837
+ # resolve its class, and on Rails 6.0 (no connection_db_config).
838
+ #
839
+ # @param assoc [ActiveRecord::Reflection::AbstractReflection]
840
+ # @return [String, nil]
841
+ def through_association_database(assoc)
842
+ return nil unless assoc.options[:through]
843
+
844
+ database_name_for(assoc.through_reflection.klass)
845
+ rescue StandardError
846
+ nil
847
+ end
848
+
849
+ # Foreign keys declared on the model's table, as the connection
850
+ # reports them. The target table's database is intentionally not
851
+ # resolved here, that is left to the graph-level cross_database_edges
852
+ # report, which resolves it from table owners.
853
+ #
854
+ # @param model [Class]
855
+ # @return [Array<Hash>] `{ from_table:, to_table:, column: }`
856
+ def extract_foreign_keys(model)
857
+ return [] unless model.table_exists?
858
+
859
+ model.connection.foreign_keys(model.table_name).map do |fk|
860
+ { from_table: fk.from_table.to_s, to_table: fk.to_table.to_s, column: fk.column.to_s }
861
+ end
862
+ rescue StandardError
863
+ []
864
+ end
865
+
866
+ # Build a parse-friendly composite for callback side-effect analysis.
867
+ #
868
+ # The displayed source_code inlines concern code as comment lines — a
869
+ # deliberate presentation choice — but Prism cannot see commented
870
+ # code, so the standard idiom (a concern's +included do+ registering a
871
+ # callback whose method is defined in the concern body) was invisible
872
+ # to CallbackAnalyzer. This composite concatenates the raw model
873
+ # source with each concern's raw module source: top-level modules
874
+ # parse fine and the analyzer's method search walks the whole tree.
875
+ #
876
+ # @param model [Class] The ActiveRecord model class
877
+ # @param source [String, nil] The raw model source
878
+ # @return [String, nil] Concatenated raw sources, or +source+ when no
879
+ # concern source is resolvable
880
+ def build_analysis_source(model, source)
881
+ return source unless source
882
+
883
+ concern_codes = resolved_concern_sources(model).map { |_name, code| code }
884
+ return source if concern_codes.empty?
885
+
886
+ ([source] + concern_codes).join("\n\n")
887
+ end
888
+
660
889
  # Enrich callback metadata with side-effect analysis.
661
890
  #
662
891
  # Uses CallbackAnalyzer to find each callback's method body and
663
892
  # classify its side effects (column writes, job enqueues, etc.).
893
+ # The analyzer parses real Ruby, so callers should hand it a
894
+ # parse-friendly +analysis_source+ (raw model + raw concern code, see
895
+ # {#build_analysis_source}) rather than the display composite whose
896
+ # concern bodies are commented out. Falls back to +unit.source_code+
897
+ # when no analysis source is given.
664
898
  #
665
899
  # @param unit [ExtractedUnit] The model unit with metadata[:callbacks] set
666
900
  # @param source [String, nil] The model source code
667
- def enrich_callbacks_with_side_effects(unit, source)
901
+ # @param analysis_source [String, nil] Parse-friendly composite source
902
+ def enrich_callbacks_with_side_effects(unit, source, analysis_source: nil)
668
903
  return unless source && unit.metadata[:callbacks]&.any?
669
904
 
670
905
  analyzer = CallbackAnalyzer.new(
671
- source_code: unit.source_code,
906
+ source_code: analysis_source || unit.source_code,
672
907
  column_names: unit.metadata[:column_names] || []
673
908
  )
674
909
 
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'pathname'
5
+
6
+ module Woods
7
+ module Extractors
8
+ # PackageExtractor reads Packwerk / pks package boundaries (#280).
9
+ #
10
+ # One unit per `package.yml`, identified the way Packwerk names packages:
11
+ # the package directory relative to Rails.root, with `.` for the root
12
+ # package. Declared dependencies become `:package_dependency` edges, so
13
+ # the graph can tell a declared cross-package call from an undeclared
14
+ # one ({Woods::GraphAnalyzer#undeclared_package_edges}). Enforcement stays
15
+ # with `packwerk check` / `pks check`; Woods only makes the boundary
16
+ # visible before an agent writes the call.
17
+ #
18
+ # Pure file read, no Rails boot needed. Registered as a whole-app
19
+ # extractor: a package root decides which package every other unit
20
+ # belongs to, so any `package.yml` change re-runs it wholesale.
21
+ #
22
+ # @example
23
+ # extractor = PackageExtractor.new
24
+ # extractor.extract_all.map(&:identifier) # => [".", "packs/billing"]
25
+ # extractor.package_for("packs/billing/app/x.rb") # => "packs/billing"
26
+ #
27
+ class PackageExtractor
28
+ PACKAGE_FILE = 'package.yml'
29
+ PACKWERK_CONFIG = 'packwerk.yml'
30
+ ROOT_PACKAGE = '.'
31
+
32
+ # Packwerk's own defaults when packwerk.yml is absent or silent.
33
+ DEFAULT_PACKAGE_PATHS = ['**/'].freeze
34
+ DEFAULT_EXCLUDE = ['{bin,node_modules,script,tmp,vendor}/**/*'].freeze
35
+
36
+ def initialize
37
+ @rails_root = Pathname.new(Rails.root.to_s)
38
+ @packwerk = load_packwerk_config
39
+ end
40
+
41
+ # @return [Array<ExtractedUnit>] one unit per package
42
+ def extract_all
43
+ package_files.filter_map { |path| extract_package_file(path) }
44
+ end
45
+
46
+ # Extract one package.yml into a unit.
47
+ #
48
+ # @param file_path [String] absolute path to a package.yml
49
+ # @return [ExtractedUnit, nil] nil when the file is not a YAML mapping
50
+ def extract_package_file(file_path)
51
+ source = File.read(file_path)
52
+ data = YAML.safe_load(source, permitted_classes: [Symbol], aliases: true) || {}
53
+ raise ArgumentError, 'package.yml is not a mapping' unless data.is_a?(Hash)
54
+
55
+ name = package_name(file_path)
56
+ dependencies = Array(data['dependencies']).map(&:to_s).uniq.sort
57
+
58
+ unit = ExtractedUnit.new(type: :package, identifier: name, file_path: file_path)
59
+ unit.source_code = source
60
+ unit.metadata = {
61
+ name: name,
62
+ dependencies: dependencies,
63
+ enforce_dependencies: normalize_enforcement(data['enforce_dependencies']),
64
+ enforce_privacy: normalize_enforcement(data['enforce_privacy']),
65
+ layer: data['layer'],
66
+ public_path: data['public_path'] || 'app/public',
67
+ owner: data.dig('metadata', 'owner') || data['owner']
68
+ }
69
+ unit.dependencies = dependencies.map { |dep| { type: :package, target: dep, via: :package_dependency } }
70
+ unit
71
+ rescue StandardError => e
72
+ Rails.logger.error("Failed to extract package from #{file_path}: #{e.message}")
73
+ nil
74
+ end
75
+
76
+ # Package names, longest path first so a nested package wins over its
77
+ # parent, with the root package last. Memoized per instance: an
78
+ # instance lives for one extraction run.
79
+ #
80
+ # @return [Array<String>]
81
+ def package_roots
82
+ @package_roots ||= package_files.map { |path| package_name(path) }
83
+ .sort_by { |name| [name == ROOT_PACKAGE ? 1 : 0, -name.length, name] }
84
+ end
85
+
86
+ # The package a path belongs to.
87
+ #
88
+ # @param path [String, Pathname] absolute or Rails.root-relative
89
+ # @return [String, nil] nil outside Rails.root or when no package claims it
90
+ def package_for(path)
91
+ relative = relativize(path)
92
+ return nil if relative.nil?
93
+
94
+ package_roots.find { |root| root == ROOT_PACKAGE || relative.start_with?("#{root}/") }
95
+ end
96
+
97
+ private
98
+
99
+ # Every package.yml the packwerk configuration admits, sorted.
100
+ #
101
+ # @return [Array<String>] absolute paths
102
+ def package_files
103
+ @package_files ||= begin
104
+ patterns = Array(@packwerk['package_paths'] || DEFAULT_PACKAGE_PATHS)
105
+ excludes = Array(@packwerk['exclude'] || DEFAULT_EXCLUDE)
106
+ found = patterns.flat_map do |pattern|
107
+ Dir.glob(@rails_root.join(pattern.to_s, PACKAGE_FILE).to_s)
108
+ end
109
+ found.uniq.reject { |path| excluded?(relativize(path), excludes) }.sort
110
+ end
111
+ end
112
+
113
+ def excluded?(relative, excludes)
114
+ excludes.any? { |glob| File.fnmatch?(glob.to_s, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) }
115
+ end
116
+
117
+ # @return [Hash] parsed packwerk.yml, or {} when absent or unreadable
118
+ def load_packwerk_config
119
+ path = @rails_root.join(PACKWERK_CONFIG)
120
+ return {} unless path.file?
121
+
122
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true)
123
+ data.is_a?(Hash) ? data : {}
124
+ rescue StandardError => e
125
+ Rails.logger.warn("[Woods] Could not read #{PACKWERK_CONFIG}: #{e.message}")
126
+ {}
127
+ end
128
+
129
+ # @param file_path [String] absolute path to a package.yml
130
+ # @return [String] Packwerk package name
131
+ def package_name(file_path)
132
+ dir = File.dirname(relativize(file_path).to_s)
133
+ dir == '.' ? ROOT_PACKAGE : dir
134
+ end
135
+
136
+ # @return [String, nil] Rails.root-relative path, nil when outside the root
137
+ def relativize(path)
138
+ string = path.to_s
139
+ return string unless string.start_with?('/')
140
+
141
+ prefix = "#{@rails_root}/"
142
+ string.start_with?(prefix) ? string.delete_prefix(prefix) : nil
143
+ end
144
+
145
+ # Packwerk accepts true, false, and "strict"; keep the value shape.
146
+ def normalize_enforcement(value)
147
+ return false if value.nil? || value == false
148
+ return true if value == true
149
+
150
+ value.to_s
151
+ end
152
+ end
153
+ end
154
+ end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'component_discovery'
3
4
  require_relative 'shared_utility_methods'
4
5
  require_relative 'shared_dependency_scanner'
5
6
  require_relative 'route_helper_resolver'
@@ -22,6 +23,7 @@ module Woods
22
23
  # card = units.find { |u| u.identifier == "Components::CardComponent" }
23
24
  #
24
25
  class PhlexExtractor
26
+ include ComponentDiscovery
25
27
  include SharedUtilityMethods
26
28
  include SharedDependencyScanner
27
29
  include RouteHelperResolver
@@ -45,13 +47,26 @@ module Woods
45
47
  #
46
48
  # @return [Array<ExtractedUnit>] List of component units
47
49
  def extract_all
48
- return [] unless @component_base
49
-
50
- @component_base.descendants.map do |component|
50
+ discoverable_classes.map do |component|
51
51
  extract_component(component)
52
52
  end.compact
53
53
  end
54
54
 
55
+ # The component classes this extractor would extract from the running
56
+ # app. Shared with the incremental path's class reconciliation (#164).
57
+ #
58
+ # The component directories are walked first: `descendants` only knows
59
+ # what something has already loaded, and a component under an autoloaded
60
+ # but not eager-loaded subtree of app/views is loaded by nothing (B-184).
61
+ #
62
+ # @return [Array<Class>]
63
+ def discoverable_classes
64
+ return [] unless @component_base
65
+
66
+ load_component_files
67
+ @component_base.descendants
68
+ end
69
+
55
70
  # Extract a single component
56
71
  #
57
72
  # @param component [Class] The component class