woods 1.0.0

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 (185) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +89 -0
  3. data/CODE_OF_CONDUCT.md +83 -0
  4. data/CONTRIBUTING.md +65 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +406 -0
  7. data/exe/woods-console +59 -0
  8. data/exe/woods-console-mcp +22 -0
  9. data/exe/woods-mcp +34 -0
  10. data/exe/woods-mcp-http +37 -0
  11. data/exe/woods-mcp-start +58 -0
  12. data/lib/generators/woods/install_generator.rb +32 -0
  13. data/lib/generators/woods/pgvector_generator.rb +37 -0
  14. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +15 -0
  15. data/lib/generators/woods/templates/create_woods_tables.rb.erb +43 -0
  16. data/lib/tasks/woods.rake +621 -0
  17. data/lib/tasks/woods_evaluation.rake +115 -0
  18. data/lib/woods/ast/call_site_extractor.rb +106 -0
  19. data/lib/woods/ast/method_extractor.rb +71 -0
  20. data/lib/woods/ast/node.rb +116 -0
  21. data/lib/woods/ast/parser.rb +614 -0
  22. data/lib/woods/ast.rb +6 -0
  23. data/lib/woods/builder.rb +200 -0
  24. data/lib/woods/cache/cache_middleware.rb +199 -0
  25. data/lib/woods/cache/cache_store.rb +264 -0
  26. data/lib/woods/cache/redis_cache_store.rb +116 -0
  27. data/lib/woods/cache/solid_cache_store.rb +111 -0
  28. data/lib/woods/chunking/chunk.rb +84 -0
  29. data/lib/woods/chunking/semantic_chunker.rb +295 -0
  30. data/lib/woods/console/adapters/cache_adapter.rb +58 -0
  31. data/lib/woods/console/adapters/good_job_adapter.rb +33 -0
  32. data/lib/woods/console/adapters/job_adapter.rb +68 -0
  33. data/lib/woods/console/adapters/sidekiq_adapter.rb +33 -0
  34. data/lib/woods/console/adapters/solid_queue_adapter.rb +33 -0
  35. data/lib/woods/console/audit_logger.rb +75 -0
  36. data/lib/woods/console/bridge.rb +177 -0
  37. data/lib/woods/console/confirmation.rb +90 -0
  38. data/lib/woods/console/connection_manager.rb +173 -0
  39. data/lib/woods/console/console_response_renderer.rb +74 -0
  40. data/lib/woods/console/embedded_executor.rb +373 -0
  41. data/lib/woods/console/model_validator.rb +81 -0
  42. data/lib/woods/console/rack_middleware.rb +87 -0
  43. data/lib/woods/console/safe_context.rb +82 -0
  44. data/lib/woods/console/server.rb +612 -0
  45. data/lib/woods/console/sql_validator.rb +172 -0
  46. data/lib/woods/console/tools/tier1.rb +118 -0
  47. data/lib/woods/console/tools/tier2.rb +117 -0
  48. data/lib/woods/console/tools/tier3.rb +110 -0
  49. data/lib/woods/console/tools/tier4.rb +79 -0
  50. data/lib/woods/coordination/pipeline_lock.rb +109 -0
  51. data/lib/woods/cost_model/embedding_cost.rb +88 -0
  52. data/lib/woods/cost_model/estimator.rb +128 -0
  53. data/lib/woods/cost_model/provider_pricing.rb +67 -0
  54. data/lib/woods/cost_model/storage_cost.rb +52 -0
  55. data/lib/woods/cost_model.rb +22 -0
  56. data/lib/woods/db/migrations/001_create_units.rb +38 -0
  57. data/lib/woods/db/migrations/002_create_edges.rb +35 -0
  58. data/lib/woods/db/migrations/003_create_embeddings.rb +37 -0
  59. data/lib/woods/db/migrations/004_create_snapshots.rb +45 -0
  60. data/lib/woods/db/migrations/005_create_snapshot_units.rb +40 -0
  61. data/lib/woods/db/migrations/006_rename_tables.rb +34 -0
  62. data/lib/woods/db/migrator.rb +73 -0
  63. data/lib/woods/db/schema_version.rb +73 -0
  64. data/lib/woods/dependency_graph.rb +236 -0
  65. data/lib/woods/embedding/indexer.rb +140 -0
  66. data/lib/woods/embedding/openai.rb +126 -0
  67. data/lib/woods/embedding/provider.rb +162 -0
  68. data/lib/woods/embedding/text_preparer.rb +112 -0
  69. data/lib/woods/evaluation/baseline_runner.rb +115 -0
  70. data/lib/woods/evaluation/evaluator.rb +139 -0
  71. data/lib/woods/evaluation/metrics.rb +79 -0
  72. data/lib/woods/evaluation/query_set.rb +148 -0
  73. data/lib/woods/evaluation/report_generator.rb +90 -0
  74. data/lib/woods/extracted_unit.rb +145 -0
  75. data/lib/woods/extractor.rb +1028 -0
  76. data/lib/woods/extractors/action_cable_extractor.rb +201 -0
  77. data/lib/woods/extractors/ast_source_extraction.rb +46 -0
  78. data/lib/woods/extractors/behavioral_profile.rb +309 -0
  79. data/lib/woods/extractors/caching_extractor.rb +261 -0
  80. data/lib/woods/extractors/callback_analyzer.rb +246 -0
  81. data/lib/woods/extractors/concern_extractor.rb +292 -0
  82. data/lib/woods/extractors/configuration_extractor.rb +219 -0
  83. data/lib/woods/extractors/controller_extractor.rb +404 -0
  84. data/lib/woods/extractors/database_view_extractor.rb +278 -0
  85. data/lib/woods/extractors/decorator_extractor.rb +253 -0
  86. data/lib/woods/extractors/engine_extractor.rb +223 -0
  87. data/lib/woods/extractors/event_extractor.rb +211 -0
  88. data/lib/woods/extractors/factory_extractor.rb +289 -0
  89. data/lib/woods/extractors/graphql_extractor.rb +892 -0
  90. data/lib/woods/extractors/i18n_extractor.rb +117 -0
  91. data/lib/woods/extractors/job_extractor.rb +374 -0
  92. data/lib/woods/extractors/lib_extractor.rb +218 -0
  93. data/lib/woods/extractors/mailer_extractor.rb +269 -0
  94. data/lib/woods/extractors/manager_extractor.rb +188 -0
  95. data/lib/woods/extractors/middleware_extractor.rb +133 -0
  96. data/lib/woods/extractors/migration_extractor.rb +469 -0
  97. data/lib/woods/extractors/model_extractor.rb +988 -0
  98. data/lib/woods/extractors/phlex_extractor.rb +252 -0
  99. data/lib/woods/extractors/policy_extractor.rb +191 -0
  100. data/lib/woods/extractors/poro_extractor.rb +229 -0
  101. data/lib/woods/extractors/pundit_extractor.rb +223 -0
  102. data/lib/woods/extractors/rails_source_extractor.rb +473 -0
  103. data/lib/woods/extractors/rake_task_extractor.rb +343 -0
  104. data/lib/woods/extractors/route_extractor.rb +181 -0
  105. data/lib/woods/extractors/scheduled_job_extractor.rb +331 -0
  106. data/lib/woods/extractors/serializer_extractor.rb +339 -0
  107. data/lib/woods/extractors/service_extractor.rb +217 -0
  108. data/lib/woods/extractors/shared_dependency_scanner.rb +91 -0
  109. data/lib/woods/extractors/shared_utility_methods.rb +281 -0
  110. data/lib/woods/extractors/state_machine_extractor.rb +398 -0
  111. data/lib/woods/extractors/test_mapping_extractor.rb +225 -0
  112. data/lib/woods/extractors/validator_extractor.rb +211 -0
  113. data/lib/woods/extractors/view_component_extractor.rb +311 -0
  114. data/lib/woods/extractors/view_template_extractor.rb +261 -0
  115. data/lib/woods/feedback/gap_detector.rb +89 -0
  116. data/lib/woods/feedback/store.rb +119 -0
  117. data/lib/woods/filename_utils.rb +32 -0
  118. data/lib/woods/flow_analysis/operation_extractor.rb +206 -0
  119. data/lib/woods/flow_analysis/response_code_mapper.rb +154 -0
  120. data/lib/woods/flow_assembler.rb +290 -0
  121. data/lib/woods/flow_document.rb +191 -0
  122. data/lib/woods/flow_precomputer.rb +102 -0
  123. data/lib/woods/formatting/base.rb +30 -0
  124. data/lib/woods/formatting/claude_adapter.rb +98 -0
  125. data/lib/woods/formatting/generic_adapter.rb +56 -0
  126. data/lib/woods/formatting/gpt_adapter.rb +64 -0
  127. data/lib/woods/formatting/human_adapter.rb +78 -0
  128. data/lib/woods/graph_analyzer.rb +374 -0
  129. data/lib/woods/mcp/bootstrapper.rb +96 -0
  130. data/lib/woods/mcp/index_reader.rb +394 -0
  131. data/lib/woods/mcp/renderers/claude_renderer.rb +81 -0
  132. data/lib/woods/mcp/renderers/json_renderer.rb +17 -0
  133. data/lib/woods/mcp/renderers/markdown_renderer.rb +353 -0
  134. data/lib/woods/mcp/renderers/plain_renderer.rb +240 -0
  135. data/lib/woods/mcp/server.rb +962 -0
  136. data/lib/woods/mcp/tool_response_renderer.rb +85 -0
  137. data/lib/woods/model_name_cache.rb +51 -0
  138. data/lib/woods/notion/client.rb +217 -0
  139. data/lib/woods/notion/exporter.rb +219 -0
  140. data/lib/woods/notion/mapper.rb +40 -0
  141. data/lib/woods/notion/mappers/column_mapper.rb +57 -0
  142. data/lib/woods/notion/mappers/migration_mapper.rb +39 -0
  143. data/lib/woods/notion/mappers/model_mapper.rb +161 -0
  144. data/lib/woods/notion/mappers/shared.rb +22 -0
  145. data/lib/woods/notion/rate_limiter.rb +68 -0
  146. data/lib/woods/observability/health_check.rb +79 -0
  147. data/lib/woods/observability/instrumentation.rb +34 -0
  148. data/lib/woods/observability/structured_logger.rb +57 -0
  149. data/lib/woods/operator/error_escalator.rb +81 -0
  150. data/lib/woods/operator/pipeline_guard.rb +92 -0
  151. data/lib/woods/operator/status_reporter.rb +80 -0
  152. data/lib/woods/railtie.rb +38 -0
  153. data/lib/woods/resilience/circuit_breaker.rb +99 -0
  154. data/lib/woods/resilience/index_validator.rb +167 -0
  155. data/lib/woods/resilience/retryable_provider.rb +108 -0
  156. data/lib/woods/retrieval/context_assembler.rb +261 -0
  157. data/lib/woods/retrieval/query_classifier.rb +133 -0
  158. data/lib/woods/retrieval/ranker.rb +277 -0
  159. data/lib/woods/retrieval/search_executor.rb +316 -0
  160. data/lib/woods/retriever.rb +152 -0
  161. data/lib/woods/ruby_analyzer/class_analyzer.rb +170 -0
  162. data/lib/woods/ruby_analyzer/dataflow_analyzer.rb +77 -0
  163. data/lib/woods/ruby_analyzer/fqn_builder.rb +18 -0
  164. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +280 -0
  165. data/lib/woods/ruby_analyzer/method_analyzer.rb +143 -0
  166. data/lib/woods/ruby_analyzer/trace_enricher.rb +143 -0
  167. data/lib/woods/ruby_analyzer.rb +87 -0
  168. data/lib/woods/session_tracer/file_store.rb +104 -0
  169. data/lib/woods/session_tracer/middleware.rb +143 -0
  170. data/lib/woods/session_tracer/redis_store.rb +106 -0
  171. data/lib/woods/session_tracer/session_flow_assembler.rb +254 -0
  172. data/lib/woods/session_tracer/session_flow_document.rb +223 -0
  173. data/lib/woods/session_tracer/solid_cache_store.rb +139 -0
  174. data/lib/woods/session_tracer/store.rb +81 -0
  175. data/lib/woods/storage/graph_store.rb +120 -0
  176. data/lib/woods/storage/metadata_store.rb +196 -0
  177. data/lib/woods/storage/pgvector.rb +195 -0
  178. data/lib/woods/storage/qdrant.rb +205 -0
  179. data/lib/woods/storage/vector_store.rb +167 -0
  180. data/lib/woods/temporal/json_snapshot_store.rb +245 -0
  181. data/lib/woods/temporal/snapshot_store.rb +345 -0
  182. data/lib/woods/token_utils.rb +19 -0
  183. data/lib/woods/version.rb +5 -0
  184. data/lib/woods.rb +246 -0
  185. metadata +270 -0
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'shared_utility_methods'
4
+ require_relative 'shared_dependency_scanner'
5
+
6
+ module Woods
7
+ module Extractors
8
+ # FactoryExtractor handles extraction of FactoryBot factory definitions.
9
+ #
10
+ # Scans spec/factories/ and test/factories/ for FactoryBot definitions
11
+ # and produces one ExtractedUnit per factory block. Uses a line-by-line
12
+ # state machine parser (never evals factory files).
13
+ #
14
+ # Supports: basic factories, explicit class override, traits, associations,
15
+ # sequences, callbacks, parent inheritance, transient attributes, and
16
+ # nested factory definitions (each becomes its own unit).
17
+ #
18
+ # @example
19
+ # extractor = FactoryExtractor.new
20
+ # units = extractor.extract_all
21
+ # user = units.find { |u| u.identifier == "user" }
22
+ # user.metadata[:traits] # => ["admin", "with_avatar"]
23
+ #
24
+ class FactoryExtractor
25
+ include SharedUtilityMethods
26
+ include SharedDependencyScanner
27
+
28
+ FACTORY_DIRECTORIES = %w[spec/factories test/factories].freeze
29
+
30
+ def initialize
31
+ @directories = FACTORY_DIRECTORIES.map { |d| Rails.root.join(d) }.select(&:directory?)
32
+ end
33
+
34
+ # Extract all factory definitions from all discovered directories.
35
+ #
36
+ # @return [Array<ExtractedUnit>] List of factory units
37
+ def extract_all
38
+ @directories.flat_map do |dir|
39
+ Dir[dir.join('**/*.rb')].flat_map { |file| extract_factory_file(file) }
40
+ end
41
+ end
42
+
43
+ # Extract factory definitions from a single factory file.
44
+ #
45
+ # Returns an Array because each file may contain multiple factory definitions.
46
+ #
47
+ # @param file_path [String] Path to the factory file
48
+ # @return [Array<ExtractedUnit>] List of factory units
49
+ def extract_factory_file(file_path)
50
+ return [] unless file_path.to_s.end_with?('.rb')
51
+
52
+ source = File.read(file_path)
53
+ factories = parse_factories(source)
54
+
55
+ factories.map { |factory_data| build_unit(factory_data, file_path, source) }
56
+ rescue StandardError => e
57
+ Rails.logger.error("Failed to extract factories from #{file_path}: #{e.message}")
58
+ []
59
+ end
60
+
61
+ private
62
+
63
+ # Parse factory definitions from source using a line-by-line state machine.
64
+ #
65
+ # Tracks factory nesting, traits, associations, sequences, callbacks, and
66
+ # transient attributes. Each factory block (including nested factories within
67
+ # a parent factory) produces one entry in the returned array.
68
+ #
69
+ # @param source [String] Factory file source code
70
+ # @return [Array<Hash>] Parsed factory data hashes
71
+ def parse_factories(source)
72
+ completed = []
73
+ factory_stack = []
74
+ depth = 0
75
+ in_transient = false
76
+ transient_depth = nil
77
+
78
+ source.lines.each_with_index do |line, index|
79
+ stripped = line.strip
80
+
81
+ # Factory definition — push new factory onto stack
82
+ if (factory_data = match_factory(stripped, depth, index + 1))
83
+ factory_stack.push(factory_data)
84
+ depth += 1
85
+ next
86
+ end
87
+
88
+ # Trait definition — record trait in current factory, open block
89
+ if (trait_match = stripped.match(/\Atrait\s+:(\w+)\s+do/))
90
+ factory_stack.last[:traits] << trait_match[1] if factory_stack.any?
91
+ depth += 1
92
+ next
93
+ end
94
+
95
+ # Transient block — start collecting transient attributes
96
+ if stripped.match?(/\Atransient\s+do/)
97
+ in_transient = true
98
+ transient_depth = depth
99
+ depth += 1
100
+ next
101
+ end
102
+
103
+ # Collect transient attribute names (word { ... } or word do)
104
+ if in_transient && factory_stack.any? && (attr_match = stripped.match(/\A(\w+)\s*(?:\{|do\b)/))
105
+ factory_stack.last[:transient_attributes] << attr_match[1]
106
+ end
107
+
108
+ # Association
109
+ if factory_stack.any? && (assoc_match = stripped.match(/\Aassociation\s+:(\w+)/))
110
+ factory_stack.last[:associations] << assoc_match[1]
111
+ end
112
+
113
+ # Sequence
114
+ if factory_stack.any? && (seq_match = stripped.match(/\Asequence\s*\(:(\w+)\)/))
115
+ factory_stack.last[:sequences] << seq_match[1]
116
+ end
117
+
118
+ # Callbacks: after(:hook), before(:hook), after_stub(:hook)
119
+ if factory_stack.any? && (cb_match = stripped.match(/\A(?:after|before|after_stub)\s*\([:'"](\w+)/))
120
+ factory_stack.last[:callbacks] << cb_match[1]
121
+ end
122
+
123
+ # Generic block openers — factory/trait/transient already handled above with next
124
+ if block_opener?(stripped)
125
+ depth += 1
126
+ next
127
+ end
128
+
129
+ next unless stripped == 'end'
130
+
131
+ depth -= 1
132
+
133
+ # Close transient block if we've returned to the depth where it was opened
134
+ if in_transient && depth == transient_depth
135
+ in_transient = false
136
+ transient_depth = nil
137
+ end
138
+
139
+ # Close factory if top factory was opened at this depth
140
+ next unless factory_stack.any? && depth == factory_stack.last[:open_depth]
141
+
142
+ completed << factory_stack.pop
143
+ end
144
+
145
+ completed
146
+ end
147
+
148
+ # Try to match a factory definition line and return initialized factory data.
149
+ #
150
+ # Handles:
151
+ # factory :name do
152
+ # factory :name, class: ClassName do
153
+ # factory :name, class: 'ClassName' do
154
+ # factory :name, parent: :other do
155
+ #
156
+ # @param line [String] Stripped source line
157
+ # @param depth [Integer] Current block depth when factory would be opened
158
+ # @param line_number [Integer] 1-based line number
159
+ # @return [Hash, nil] Initialized factory data or nil if not a factory line
160
+ def match_factory(line, depth, line_number)
161
+ return nil unless line.match?(/\Afactory\s+:/) && line.match?(/\bdo\b/)
162
+
163
+ name_match = line.match(/\Afactory\s+:(\w+)/)
164
+ return nil unless name_match
165
+
166
+ name = name_match[1]
167
+ options = {}
168
+
169
+ if (class_match = line.match(/\bclass:\s*['"]?([\w:]+)['"]?/))
170
+ options[:class_name] = class_match[1]
171
+ end
172
+
173
+ if (parent_match = line.match(/\bparent:\s*:(\w+)/))
174
+ options[:parent] = parent_match[1]
175
+ end
176
+
177
+ {
178
+ name: name,
179
+ class_name: options[:class_name] || classify(name),
180
+ parent_factory: options[:parent],
181
+ open_depth: depth,
182
+ line_number: line_number,
183
+ traits: [],
184
+ associations: [],
185
+ sequences: [],
186
+ callbacks: [],
187
+ transient_attributes: []
188
+ }
189
+ end
190
+
191
+ # Convert a snake_case factory name to a CamelCase class name.
192
+ #
193
+ # @param name [String] Snake_case factory name (e.g., "admin_user")
194
+ # @return [String] CamelCase class name (e.g., "AdminUser")
195
+ def classify(name)
196
+ name.split('_').map(&:capitalize).join
197
+ end
198
+
199
+ # Check if a stripped line opens a new block.
200
+ #
201
+ # Excludes factory, trait, and transient lines — those are handled
202
+ # explicitly in the main parser loop with depth tracking of their own.
203
+ #
204
+ # @param stripped [String] Stripped line content
205
+ # @return [Boolean]
206
+ def block_opener?(stripped)
207
+ return false if stripped.match?(/\Afactory\s+:/)
208
+ return false if stripped.match?(/\Atrait\s+:/)
209
+ return false if stripped.match?(/\Atransient\s+do/)
210
+ return true if stripped.match?(/\b(do|def|case|begin|class|module|while|until|for)\b.*(?<!\bend)\s*$/)
211
+
212
+ stripped.match?(/\A(if|unless)\b/)
213
+ end
214
+
215
+ # Build an ExtractedUnit from parsed factory data.
216
+ #
217
+ # @param factory_data [Hash] Parsed factory data
218
+ # @param file_path [String] Path to the factory file
219
+ # @param file_source [String] Full file source
220
+ # @return [ExtractedUnit]
221
+ def build_unit(factory_data, file_path, file_source)
222
+ unit = ExtractedUnit.new(
223
+ type: :factory,
224
+ identifier: factory_data[:name],
225
+ file_path: file_path
226
+ )
227
+
228
+ unit.source_code = build_source_annotation(factory_data, file_source)
229
+ unit.metadata = build_metadata(factory_data)
230
+ unit.dependencies = extract_dependencies(factory_data)
231
+
232
+ unit
233
+ end
234
+
235
+ # Build annotated source code for the unit.
236
+ #
237
+ # @param factory_data [Hash] Parsed factory data
238
+ # @param file_source [String] Full file source
239
+ # @return [String]
240
+ def build_source_annotation(factory_data, file_source)
241
+ header = "# Factory: #{factory_data[:name]} (model: #{factory_data[:class_name]})"
242
+ header += "\n# Parent: #{factory_data[:parent_factory]}" if factory_data[:parent_factory]
243
+ "#{header}\n#{file_source}"
244
+ end
245
+
246
+ # Build metadata hash for the unit.
247
+ #
248
+ # @param factory_data [Hash] Parsed factory data
249
+ # @return [Hash]
250
+ def build_metadata(factory_data)
251
+ {
252
+ factory_name: factory_data[:name],
253
+ model_class: factory_data[:class_name],
254
+ traits: factory_data[:traits],
255
+ associations: factory_data[:associations],
256
+ sequences: factory_data[:sequences],
257
+ parent_factory: factory_data[:parent_factory],
258
+ callbacks: factory_data[:callbacks].uniq,
259
+ transient_attributes: factory_data[:transient_attributes]
260
+ }
261
+ end
262
+
263
+ # Extract dependencies from factory data.
264
+ #
265
+ # Creates:
266
+ # - :model dependency (via :factory_for) linking to the modeled class
267
+ # - :factory dependency (via :factory_parent) for parent factory inheritance
268
+ # - :factory dependencies (via :factory_association) for each association
269
+ #
270
+ # @param factory_data [Hash] Parsed factory data
271
+ # @return [Array<Hash>]
272
+ def extract_dependencies(factory_data)
273
+ deps = []
274
+
275
+ deps << { type: :model, target: factory_data[:class_name], via: :factory_for }
276
+
277
+ if factory_data[:parent_factory]
278
+ deps << { type: :factory, target: factory_data[:parent_factory], via: :factory_parent }
279
+ end
280
+
281
+ factory_data[:associations].each do |assoc|
282
+ deps << { type: :factory, target: assoc, via: :factory_association }
283
+ end
284
+
285
+ deps.uniq { |d| [d[:type], d[:target]] }
286
+ end
287
+ end
288
+ end
289
+ end