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,211 @@
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
+ # ValidatorExtractor handles custom validator class extraction.
9
+ #
10
+ # Custom validators encapsulate reusable validation logic that applies
11
+ # across multiple models. They inherit from `ActiveModel::Validator`
12
+ # or `ActiveModel::EachValidator` and live in `app/validators/`.
13
+ #
14
+ # We extract:
15
+ # - Validator name and namespace
16
+ # - Base class (Validator vs EachValidator)
17
+ # - Validation rules (what they check)
18
+ # - Models they operate on (from source references)
19
+ # - Dependencies (what models/services they reference)
20
+ #
21
+ # @example
22
+ # extractor = ValidatorExtractor.new
23
+ # units = extractor.extract_all
24
+ # email = units.find { |u| u.identifier == "EmailFormatValidator" }
25
+ #
26
+ class ValidatorExtractor
27
+ include SharedUtilityMethods
28
+ include SharedDependencyScanner
29
+
30
+ # Directories to scan for custom validators
31
+ VALIDATOR_DIRECTORIES = %w[
32
+ app/validators
33
+ ].freeze
34
+
35
+ def initialize
36
+ @directories = VALIDATOR_DIRECTORIES.map { |d| Rails.root.join(d) }
37
+ .select(&:directory?)
38
+ end
39
+
40
+ # Extract all custom validators
41
+ #
42
+ # @return [Array<ExtractedUnit>] List of validator units
43
+ def extract_all
44
+ @directories.flat_map do |dir|
45
+ Dir[dir.join('**/*.rb')].filter_map do |file|
46
+ extract_validator_file(file)
47
+ end
48
+ end
49
+ end
50
+
51
+ # Extract a single validator file
52
+ #
53
+ # @param file_path [String] Path to the validator file
54
+ # @return [ExtractedUnit, nil] The extracted unit or nil if not a validator
55
+ def extract_validator_file(file_path)
56
+ source = File.read(file_path)
57
+ class_name = extract_class_name(file_path, source, 'validators')
58
+
59
+ return nil unless class_name
60
+ return nil unless validator_file?(source)
61
+
62
+ unit = ExtractedUnit.new(
63
+ type: :validator,
64
+ identifier: class_name,
65
+ file_path: file_path
66
+ )
67
+
68
+ unit.namespace = extract_namespace(class_name)
69
+ unit.source_code = annotate_source(source, class_name)
70
+ unit.metadata = extract_metadata(source, class_name)
71
+ unit.dependencies = extract_dependencies(source)
72
+
73
+ unit
74
+ rescue StandardError => e
75
+ Rails.logger.error("Failed to extract validator #{file_path}: #{e.message}")
76
+ nil
77
+ end
78
+
79
+ private
80
+
81
+ # ──────────────────────────────────────────────────────────────────────
82
+ # Class Discovery
83
+ # ──────────────────────────────────────────────────────────────────────
84
+
85
+ def validator_file?(source)
86
+ source.match?(/< ActiveModel::Validator/) ||
87
+ source.match?(/< ActiveModel::EachValidator/) ||
88
+ source.match?(/def\s+validate_each\b/) ||
89
+ source.match?(/def\s+validate\(/)
90
+ end
91
+
92
+ # ──────────────────────────────────────────────────────────────────────
93
+ # Source Annotation
94
+ # ──────────────────────────────────────────────────────────────────────
95
+
96
+ def annotate_source(source, class_name)
97
+ validator_type = detect_validator_type(source)
98
+ validated_attrs = extract_validated_attributes(source)
99
+
100
+ <<~ANNOTATION
101
+ # ╔═══════════════════════════════════════════════════════════════════════╗
102
+ # ║ Validator: #{class_name.ljust(57)}║
103
+ # ║ Type: #{validator_type.to_s.ljust(62)}║
104
+ # ║ Attributes: #{validated_attrs.join(', ').ljust(56)}║
105
+ # ╚═══════════════════════════════════════════════════════════════════════╝
106
+
107
+ #{source}
108
+ ANNOTATION
109
+ end
110
+
111
+ # ──────────────────────────────────────────────────────────────────────
112
+ # Metadata Extraction
113
+ # ──────────────────────────────────────────────────────────────────────
114
+
115
+ def extract_metadata(source, class_name)
116
+ {
117
+ validator_type: detect_validator_type(source),
118
+ validated_attributes: extract_validated_attributes(source),
119
+ validation_rules: extract_validation_rules(source),
120
+ error_messages: extract_error_messages(source),
121
+ public_methods: extract_public_methods(source),
122
+ class_methods: extract_class_methods(source),
123
+ options_used: extract_options(source),
124
+ inferred_models: infer_models_from_name(class_name),
125
+ custom_errors: extract_custom_errors(source),
126
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') },
127
+ method_count: source.scan(/def\s+(?:self\.)?\w+/).size
128
+ }
129
+ end
130
+
131
+ def detect_validator_type(source)
132
+ return :each_validator if source.match?(/< ActiveModel::EachValidator/)
133
+ return :validator if source.match?(/< ActiveModel::Validator/)
134
+ return :each_validator if source.match?(/def\s+validate_each\b/)
135
+ return :validator if source.match?(/def\s+validate\(/)
136
+
137
+ :unknown
138
+ end
139
+
140
+ def extract_validated_attributes(source)
141
+ attrs = []
142
+
143
+ # EachValidator: the attribute param in validate_each
144
+ attrs << ::Regexp.last_match(1) if source =~ /def\s+validate_each\s*\(\s*\w+\s*,\s*(\w+)/
145
+
146
+ # From error.add calls: record.errors.add(:attribute, ...)
147
+ source.scan(/errors\.add\s*\(\s*:(\w+)/).flatten.each { |a| attrs << a }
148
+
149
+ # From validates_each blocks
150
+ source.scan(/validates_each\s*\(\s*:(\w+)/).flatten.each { |a| attrs << a }
151
+
152
+ attrs.uniq
153
+ end
154
+
155
+ def extract_validation_rules(source)
156
+ # Conditional checks in validate/validate_each body
157
+ rules = source.scan(/unless\s+(.+)$/).flatten.map(&:strip)
158
+ source.scan(/if\s+(.+?)(?:\s*$|\s*then)/).flatten.each { |r| rules << r.strip }
159
+
160
+ # Regex validations
161
+ source.scan(%r{=~\s*(/[^/]+/)}).flatten.each { |r| rules << "matches #{r}" }
162
+ source.scan(%r{match\?\s*\((/[^/]+/)\)}).flatten.each { |r| rules << "matches #{r}" }
163
+
164
+ rules.first(10) # Cap at 10 to avoid noise
165
+ end
166
+
167
+ def extract_error_messages(source)
168
+ # errors.add(:attr, "message") or errors.add(variable, "message")
169
+ messages = source.scan(/errors\.add\s*\(\s*:?\w+\s*,\s*["']([^"']+)["']/).flatten
170
+
171
+ # errors.add(:attr, :symbol) or errors.add(variable, :symbol)
172
+ source.scan(/errors\.add\s*\(\s*:?\w+\s*,\s*:(\w+)/).flatten.each { |m| messages << ":#{m}" }
173
+
174
+ messages
175
+ end
176
+
177
+ def extract_options(source)
178
+ # options[:key] access
179
+ options = source.scan(/options\[:(\w+)\]/).flatten
180
+
181
+ options.uniq
182
+ end
183
+
184
+ def infer_models_from_name(class_name)
185
+ # EmailFormatValidator -> might validate email on many models
186
+ # No reliable way to infer specific models from name alone
187
+ # Return the validator's conceptual domain
188
+ stripped = class_name.split('::').last
189
+ inferred = stripped.sub(/Validator\z/, '')
190
+ inferred.empty? ? [] : [inferred]
191
+ end
192
+
193
+ # ──────────────────────────────────────────────────────────────────────
194
+ # Dependency Extraction
195
+ # ──────────────────────────────────────────────────────────────────────
196
+
197
+ def extract_dependencies(source)
198
+ deps = []
199
+ deps.concat(scan_model_dependencies(source, via: :validation))
200
+ deps.concat(scan_service_dependencies(source))
201
+
202
+ # Other validators referenced
203
+ source.scan(/(\w+Validator)(?:\.|::new)/).flatten.uniq.each do |validator|
204
+ deps << { type: :validator, target: validator, via: :code_reference }
205
+ end
206
+
207
+ deps.uniq { |d| [d[:type], d[:target]] }
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,311 @@
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
+ # ViewComponentExtractor handles ViewComponent extraction.
9
+ #
10
+ # ViewComponent components are Ruby classes that encapsulate view logic.
11
+ # We can extract:
12
+ # - Slot definitions (renders_one, renders_many)
13
+ # - Sidecar template paths (.html.erb files next to the .rb file)
14
+ # - Initialize parameters (the component's API)
15
+ # - Preview classes (ViewComponent::Preview subclasses)
16
+ # - Collection support
17
+ # - Callbacks (before_render, after_render)
18
+ # - Content areas (legacy API)
19
+ # - Component dependencies (rendered sub-components, model references)
20
+ #
21
+ # @example
22
+ # extractor = ViewComponentExtractor.new
23
+ # units = extractor.extract_all
24
+ # card = units.find { |u| u.identifier == "CardComponent" }
25
+ #
26
+ class ViewComponentExtractor
27
+ include SharedUtilityMethods
28
+ include SharedDependencyScanner
29
+
30
+ def initialize
31
+ @component_base = find_component_base
32
+ end
33
+
34
+ # Extract all ViewComponent components
35
+ #
36
+ # @return [Array<ExtractedUnit>] List of view component units
37
+ def extract_all
38
+ return [] unless @component_base
39
+
40
+ @component_base.descendants.map do |component|
41
+ extract_component(component)
42
+ end.compact
43
+ end
44
+
45
+ # Extract a single ViewComponent component
46
+ #
47
+ # @param component [Class] The component class
48
+ # @return [ExtractedUnit, nil] The extracted unit, or nil on failure
49
+ def extract_component(component)
50
+ return nil if component.name.nil?
51
+ return nil if preview_class?(component)
52
+
53
+ unit = ExtractedUnit.new(
54
+ type: :view_component,
55
+ identifier: component.name,
56
+ file_path: source_file_for(component)
57
+ )
58
+
59
+ # Skip components with no resolvable source file (framework/internal)
60
+ return nil unless unit.file_path
61
+
62
+ unit.source_code = read_source(unit.file_path)
63
+
64
+ unit.namespace = extract_namespace(component)
65
+ unit.metadata = extract_metadata(component, unit.source_code)
66
+ unit.dependencies = extract_dependencies(component, unit.source_code)
67
+
68
+ unit
69
+ rescue StandardError => e
70
+ Rails.logger.error("Failed to extract view component #{component.name}: #{e.message}")
71
+ nil
72
+ end
73
+
74
+ private
75
+
76
+ # Find the ViewComponent::Base class if the gem is loaded
77
+ #
78
+ # @return [Class, nil]
79
+ def find_component_base
80
+ return nil unless defined?(ViewComponent::Base)
81
+
82
+ ViewComponent::Base
83
+ end
84
+
85
+ # Check if a class is a preview class (not a component itself)
86
+ #
87
+ # @param klass [Class]
88
+ # @return [Boolean]
89
+ def preview_class?(klass)
90
+ defined?(ViewComponent::Preview) && klass < ViewComponent::Preview
91
+ end
92
+
93
+ # Locate the source file for a component class.
94
+ #
95
+ # Convention paths first, then introspection via {#resolve_source_location}
96
+ # which filters out vendor/node_modules paths.
97
+ #
98
+ # @param component [Class]
99
+ # @return [String, nil]
100
+ def source_file_for(component)
101
+ possible_paths = [
102
+ Rails.root.join("app/components/#{component.name.underscore}.rb"),
103
+ Rails.root.join("app/views/components/#{component.name.underscore}.rb")
104
+ ]
105
+
106
+ found = possible_paths.find { |p| File.exist?(p) }
107
+ return found.to_s if found
108
+
109
+ resolve_source_location(component, app_root: Rails.root.to_s, fallback: nil)
110
+ end
111
+
112
+ # @param file_path [String, nil]
113
+ # @return [String]
114
+ def read_source(file_path)
115
+ return '' unless file_path && File.exist?(file_path)
116
+
117
+ File.read(file_path)
118
+ end
119
+
120
+ # ──────────────────────────────────────────────────────────────────────
121
+ # Metadata Extraction
122
+ # ──────────────────────────────────────────────────────────────────────
123
+
124
+ def extract_metadata(component, source)
125
+ {
126
+ slots: extract_slots(source),
127
+ initialize_params: extract_initialize_params(component),
128
+ public_methods: component.public_instance_methods(false),
129
+ parent_component: component.superclass.name,
130
+ sidecar_template: detect_sidecar_template(component),
131
+ preview_class: detect_preview_class(component),
132
+ collection_support: detect_collection_support(source),
133
+ callbacks: extract_callbacks(source),
134
+ content_areas: extract_content_areas(source),
135
+ renders_many: extract_renders_many(source),
136
+ renders_one: extract_renders_one(source),
137
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
138
+ }
139
+ end
140
+
141
+ # Extract slot definitions from renders_one / renders_many
142
+ #
143
+ # @param source [String]
144
+ # @return [Array<Hash>]
145
+ def extract_slots(source)
146
+ slots = []
147
+
148
+ source.scan(/renders_one\s+:(\w+)(?:,\s*(\w+(?:::\w+)*))?/) do |name, klass|
149
+ slots << { name: name, type: :one, class: klass }
150
+ end
151
+
152
+ source.scan(/renders_many\s+:(\w+)(?:,\s*(\w+(?:::\w+)*))?/) do |name, klass|
153
+ slots << { name: name, type: :many, class: klass }
154
+ end
155
+
156
+ slots
157
+ end
158
+
159
+ def extract_renders_many(source)
160
+ source.scan(/renders_many\s+:(\w+)/).flatten
161
+ end
162
+
163
+ def extract_renders_one(source)
164
+ source.scan(/renders_one\s+:(\w+)/).flatten
165
+ end
166
+
167
+ # Extract initialize parameters to understand the component's data requirements
168
+ #
169
+ # @param component [Class]
170
+ # @return [Array<Hash>]
171
+ def extract_initialize_params(component)
172
+ method = component.instance_method(:initialize)
173
+ params = method.parameters
174
+
175
+ params.map do |type, name|
176
+ param_type = case type
177
+ when :req then :required
178
+ when :opt then :optional
179
+ when :keyreq then :keyword_required
180
+ when :key then :keyword_optional
181
+ when :rest then :splat
182
+ when :keyrest then :double_splat
183
+ when :block then :block
184
+ else type
185
+ end
186
+ { name: name, type: param_type }
187
+ end
188
+ rescue StandardError
189
+ []
190
+ end
191
+
192
+ # Detect sidecar template file (.html.erb next to the .rb file)
193
+ #
194
+ # @param component [Class]
195
+ # @return [String, nil] Path to sidecar template if found
196
+ def detect_sidecar_template(component)
197
+ base_path = Rails.root.join("app/components/#{component.name.underscore}")
198
+
199
+ # Check common sidecar template patterns
200
+ candidates = [
201
+ "#{base_path}.html.erb",
202
+ "#{base_path}.html.haml",
203
+ "#{base_path}.html.slim",
204
+ "#{base_path}/#{component.name.demodulize.underscore}.html.erb"
205
+ ]
206
+
207
+ candidates.find { |path| File.exist?(path) }
208
+ rescue StandardError
209
+ nil
210
+ end
211
+
212
+ # Detect if a preview class exists for this component
213
+ #
214
+ # @param component [Class]
215
+ # @return [String, nil] Preview class name if found
216
+ def detect_preview_class(component)
217
+ return nil unless defined?(ViewComponent::Preview)
218
+
219
+ preview_name = "#{component.name}Preview"
220
+ klass = preview_name.safe_constantize
221
+ klass&.name if klass && klass < ViewComponent::Preview
222
+ rescue StandardError
223
+ nil
224
+ end
225
+
226
+ # Detect if the component supports collection rendering
227
+ #
228
+ # @param source [String]
229
+ # @return [Boolean]
230
+ def detect_collection_support(source)
231
+ source.match?(/with_collection_parameter/) ||
232
+ source.match?(/def\s+self\.collection_parameter/)
233
+ end
234
+
235
+ # Extract before_render / after_render callbacks
236
+ #
237
+ # @param source [String]
238
+ # @return [Array<Hash>]
239
+ def extract_callbacks(source)
240
+ callbacks = []
241
+
242
+ source.scan(/before_render\s+:(\w+)/) do |name|
243
+ callbacks << { kind: :before_render, method: name[0] }
244
+ end
245
+
246
+ source.scan(/after_render\s+:(\w+)/) do |name|
247
+ callbacks << { kind: :after_render, method: name[0] }
248
+ end
249
+
250
+ # Also detect inline before_render method override
251
+ callbacks << { kind: :before_render, method: :inline } if source.match?(/def\s+before_render\b/)
252
+
253
+ callbacks
254
+ end
255
+
256
+ # Extract legacy content_areas definitions
257
+ #
258
+ # @param source [String]
259
+ # @return [Array<String>]
260
+ def extract_content_areas(source)
261
+ source.scan(/with_content_areas\s+(.+)$/).flatten.flat_map do |area_list|
262
+ area_list.scan(/:(\w+)/).flatten
263
+ end
264
+ end
265
+
266
+ # ──────────────────────────────────────────────────────────────────────
267
+ # Dependency Extraction
268
+ # ──────────────────────────────────────────────────────────────────────
269
+
270
+ def extract_dependencies(component, source)
271
+ deps = []
272
+
273
+ # Other components rendered via render()
274
+ source.scan(/render\s*\(?\s*(\w+(?:::\w+)*)\.new/).flatten.uniq.each do |comp|
275
+ next if comp == component.name
276
+
277
+ deps << { type: :component, target: comp, via: :render }
278
+ end
279
+
280
+ # Components rendered via slot classes
281
+ source.scan(/renders_one\s+:\w+,\s*(\w+(?:::\w+)*)/).flatten.uniq.each do |comp|
282
+ deps << { type: :component, target: comp, via: :slot }
283
+ end
284
+
285
+ source.scan(/renders_many\s+:\w+,\s*(\w+(?:::\w+)*)/).flatten.uniq.each do |comp|
286
+ deps << { type: :component, target: comp, via: :slot }
287
+ end
288
+
289
+ # Model references
290
+ deps.concat(scan_model_dependencies(source, via: :data_dependency))
291
+
292
+ # Helper modules
293
+ source.scan(/include\s+(\w+Helper)/).flatten.uniq.each do |helper|
294
+ deps << { type: :helper, target: helper, via: :include }
295
+ end
296
+
297
+ # Stimulus controllers (from data-controller attributes in templates/source)
298
+ source.scan(/data[_-]controller[=:]\s*["']([^"']+)["']/).flatten.uniq.each do |controller|
299
+ deps << { type: :stimulus_controller, target: controller, via: :html_attribute }
300
+ end
301
+
302
+ # URL helpers
303
+ source.scan(/(\w+)_(?:path|url)/).flatten.uniq.each do |route|
304
+ deps << { type: :route, target: route, via: :url_helper }
305
+ end
306
+
307
+ deps.uniq { |d| [d[:type], d[:target]] }
308
+ end
309
+ end
310
+ end
311
+ end