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,892 @@
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
+ # GraphQLExtractor handles graphql-ruby type and mutation extraction.
9
+ #
10
+ # GraphQL schemas are rich in structure — types, fields, arguments,
11
+ # resolvers, and mutations form a typed API layer over the domain.
12
+ # We extract these with runtime introspection when available (via
13
+ # `GraphQL::Schema.types`) and fall back to file-based discovery
14
+ # when the schema isn't fully loadable.
15
+ #
16
+ # We extract:
17
+ # - Object types, input types, enum types, interface types, union types, scalar types
18
+ # - Mutations and their arguments/return fields
19
+ # - Query fields and resolvers
20
+ # - Standalone resolver classes
21
+ # - Field-level metadata (types, descriptions, complexity, arguments)
22
+ # - Authorization patterns (authorized?, pundit, cancan)
23
+ # - Dependencies on models, services, jobs, and other GraphQL types
24
+ #
25
+ # @example
26
+ # extractor = GraphQLExtractor.new
27
+ # units = extractor.extract_all
28
+ # user_type = units.find { |u| u.identifier == "Types::UserType" }
29
+ #
30
+ class GraphQLExtractor
31
+ include SharedUtilityMethods
32
+ include SharedDependencyScanner
33
+
34
+ # Standard directory for graphql-ruby applications
35
+ GRAPHQL_DIRECTORY = 'app/graphql'
36
+
37
+ # Token threshold for chunking large types
38
+ CHUNK_THRESHOLD = 1500
39
+
40
+ def initialize
41
+ @graphql_dir = defined?(Rails) ? Rails.root.join(GRAPHQL_DIRECTORY) : nil
42
+ @schema_class = find_schema_class
43
+ @runtime_types = load_runtime_types
44
+ end
45
+
46
+ # Extract all GraphQL types, mutations, queries, and resolvers
47
+ #
48
+ # Returns an empty array if graphql-ruby is not installed or
49
+ # no GraphQL files are found.
50
+ #
51
+ # @return [Array<ExtractedUnit>] List of GraphQL units
52
+ def extract_all
53
+ return [] unless graphql_available?
54
+
55
+ units = []
56
+ seen_identifiers = Set.new
57
+
58
+ # First pass: runtime introspection (most accurate)
59
+ if @runtime_types.any?
60
+ @runtime_types.each_value do |type_class|
61
+ unit = extract_from_runtime_type(type_class)
62
+ next unless unit
63
+ next if seen_identifiers.include?(unit.identifier)
64
+
65
+ seen_identifiers << unit.identifier
66
+ units << unit
67
+ end
68
+ end
69
+
70
+ # Second pass: file-based discovery (catches everything)
71
+ if @graphql_dir&.directory?
72
+ Dir[@graphql_dir.join('**/*.rb')].each do |file_path|
73
+ unit = extract_graphql_file(file_path)
74
+ next unless unit
75
+ next if seen_identifiers.include?(unit.identifier)
76
+
77
+ seen_identifiers << unit.identifier
78
+ units << unit
79
+ end
80
+ end
81
+
82
+ units.compact
83
+ end
84
+
85
+ # Extract a single GraphQL file
86
+ #
87
+ # @param file_path [String] Absolute path to a .rb file in app/graphql/
88
+ # @return [ExtractedUnit, nil] The extracted unit, or nil if the file
89
+ # does not contain a recognizable GraphQL class
90
+ def extract_graphql_file(file_path)
91
+ source = File.read(file_path)
92
+ class_name = extract_class_name(file_path, source)
93
+
94
+ return nil unless class_name
95
+ return nil unless graphql_class?(source)
96
+
97
+ unit_type = classify_unit_type(file_path, source)
98
+ runtime_class = class_name.safe_constantize
99
+
100
+ unit = ExtractedUnit.new(
101
+ type: unit_type,
102
+ identifier: class_name,
103
+ file_path: file_path
104
+ )
105
+
106
+ unit.namespace = extract_namespace(class_name)
107
+ unit.source_code = build_annotated_source(source, class_name, unit_type, runtime_class)
108
+ unit.metadata = build_metadata(source, class_name, unit_type, runtime_class)
109
+ unit.dependencies = extract_dependencies(source, class_name)
110
+ unit.chunks = build_chunks(unit, runtime_class) if unit.needs_chunking?(threshold: CHUNK_THRESHOLD)
111
+
112
+ unit
113
+ rescue StandardError => e
114
+ Rails.logger.error("Failed to extract GraphQL file #{file_path}: #{e.message}") if defined?(Rails)
115
+ nil
116
+ end
117
+
118
+ private
119
+
120
+ # ──────────────────────────────────────────────────────────────────────
121
+ # Schema and Runtime Discovery
122
+ # ──────────────────────────────────────────────────────────────────────
123
+
124
+ # Check if graphql-ruby is available at runtime
125
+ #
126
+ # @return [Boolean]
127
+ def graphql_available?
128
+ return false unless defined?(GraphQL::Schema)
129
+ return false unless @graphql_dir&.directory? || @schema_class
130
+
131
+ true
132
+ end
133
+
134
+ # Find the application's schema class (descendant of GraphQL::Schema)
135
+ #
136
+ # @return [Class, nil]
137
+ def find_schema_class
138
+ return nil unless defined?(GraphQL::Schema)
139
+
140
+ GraphQL::Schema.descendants.find do |klass|
141
+ klass.name && !klass.name.start_with?('GraphQL::')
142
+ end
143
+ rescue StandardError
144
+ nil
145
+ end
146
+
147
+ # Load types from the runtime schema for introspection
148
+ #
149
+ # @return [Hash{String => Class}] Map of type name to type class
150
+ def load_runtime_types
151
+ return {} unless @schema_class
152
+
153
+ types = {}
154
+ @schema_class.types.each do |name, type_class|
155
+ # Skip built-in introspection types
156
+ next if name.start_with?('__')
157
+ next unless type_class.respond_to?(:name) && type_class.name
158
+
159
+ types[name] = type_class
160
+ end
161
+
162
+ types
163
+ rescue StandardError
164
+ {}
165
+ end
166
+
167
+ # ──────────────────────────────────────────────────────────────────────
168
+ # Runtime Type Extraction
169
+ # ──────────────────────────────────────────────────────────────────────
170
+
171
+ # Extract a unit from a runtime-loaded GraphQL type class
172
+ #
173
+ # @param type_class [Class] A graphql-ruby type class
174
+ # @return [ExtractedUnit, nil]
175
+ def extract_from_runtime_type(type_class)
176
+ return nil unless type_class.respond_to?(:name) && type_class.name
177
+ # Skip anonymous or internal graphql-ruby classes
178
+ return nil if type_class.name.start_with?('GraphQL::')
179
+
180
+ file_path = source_file_for_class(type_class)
181
+ source = file_path && File.exist?(file_path) ? File.read(file_path) : ''
182
+ unit_type = classify_runtime_type(type_class)
183
+
184
+ unit = ExtractedUnit.new(
185
+ type: unit_type,
186
+ identifier: type_class.name,
187
+ file_path: file_path
188
+ )
189
+
190
+ unit.namespace = extract_namespace(type_class.name)
191
+ unit.source_code = build_annotated_source(source, type_class.name, unit_type, type_class)
192
+ unit.metadata = build_metadata(source, type_class.name, unit_type, type_class)
193
+ unit.dependencies = extract_dependencies(source, type_class.name)
194
+ unit.chunks = build_chunks(unit, type_class) if unit.needs_chunking?(threshold: CHUNK_THRESHOLD)
195
+
196
+ unit
197
+ rescue StandardError => e
198
+ Rails.logger.error("Failed to extract GraphQL type #{type_class.name}: #{e.message}") if defined?(Rails)
199
+ nil
200
+ end
201
+
202
+ # Determine the source file for a runtime-loaded class, validating that
203
+ # paths are within Rails.root to avoid returning graphql gem internals.
204
+ #
205
+ # Convention path first, then introspection via {#resolve_source_location}
206
+ # which filters out vendor/node_modules paths.
207
+ #
208
+ # @param klass [Class]
209
+ # @return [String] Absolute path to the source file
210
+ def source_file_for_class(klass)
211
+ convention_path = Rails.root.join("#{GRAPHQL_DIRECTORY}/#{klass.name.underscore}.rb").to_s
212
+ return convention_path if File.exist?(convention_path)
213
+
214
+ resolve_source_location(klass, app_root: Rails.root.to_s, fallback: convention_path)
215
+ end
216
+
217
+ # ──────────────────────────────────────────────────────────────────────
218
+ # Classification
219
+ # ──────────────────────────────────────────────────────────────────────
220
+
221
+ # Classify unit type from a runtime type class
222
+ #
223
+ # @param type_class [Class]
224
+ # @return [Symbol]
225
+ def classify_runtime_type(type_class)
226
+ if defined?(GraphQL::Schema::Mutation) && type_class < GraphQL::Schema::Mutation
227
+ :graphql_mutation
228
+ elsif defined?(GraphQL::Schema::Resolver) && type_class < GraphQL::Schema::Resolver
229
+ :graphql_resolver
230
+ elsif defined?(GraphQL::Schema::Enum) && type_class < GraphQL::Schema::Enum
231
+ :graphql_type
232
+ elsif defined?(GraphQL::Schema::Union) && type_class < GraphQL::Schema::Union
233
+ :graphql_type
234
+ elsif defined?(GraphQL::Schema::Interface) && type_class.is_a?(Module) && type_class.respond_to?(:fields)
235
+ :graphql_type
236
+ elsif defined?(GraphQL::Schema::InputObject) && type_class < GraphQL::Schema::InputObject
237
+ :graphql_type
238
+ elsif defined?(GraphQL::Schema::Scalar) && type_class < GraphQL::Schema::Scalar
239
+ :graphql_type
240
+ elsif defined?(GraphQL::Schema::Object) && type_class < GraphQL::Schema::Object
241
+ # Check if this is the Query root type
242
+ if @schema_class.respond_to?(:query) && @schema_class.query == type_class
243
+ :graphql_query
244
+ else
245
+ :graphql_type
246
+ end
247
+ else
248
+ :graphql_type
249
+ end
250
+ end
251
+
252
+ # Classify unit type from file path and source content
253
+ #
254
+ # @param file_path [String]
255
+ # @param source [String]
256
+ # @return [Symbol]
257
+ def classify_unit_type(file_path, source)
258
+ return :graphql_mutation if file_path.include?('/mutations/')
259
+ return :graphql_resolver if file_path.include?('/resolvers/')
260
+
261
+ return :graphql_mutation if source.match?(/< (GraphQL::Schema::Mutation|Mutations::Base|BaseMutation)/)
262
+
263
+ return :graphql_resolver if source.match?(/< (GraphQL::Schema::Resolver|Resolvers::Base|BaseResolver)/)
264
+
265
+ # Query type is usually the root query object
266
+ return :graphql_query if file_path.match?(/query_type\.rb$/) || source.match?(/class QueryType\b/)
267
+
268
+ :graphql_type
269
+ end
270
+
271
+ # Check if a source file contains a graphql-ruby class
272
+ #
273
+ # @param source [String]
274
+ # @return [Boolean]
275
+ def graphql_class?(source)
276
+ source.match?(/< GraphQL::Schema::(Object|InputObject|Enum|Union|Scalar|Mutation|Resolver|Interface|RelayClassicMutation)/) ||
277
+ source.match?(/< (Types::Base\w+|Base(Type|InputObject|Enum|Union|Scalar|Mutation|Resolver|Interface))/) ||
278
+ source.match?(/< (Mutations::Base|Resolvers::Base)/) ||
279
+ source.match?(/include GraphQL::Schema::Interface/) ||
280
+ (source.include?('field :') && source.match?(/< .*Type\b/))
281
+ end
282
+
283
+ # ──────────────────────────────────────────────────────────────────────
284
+ # Class Name and Namespace
285
+ # ──────────────────────────────────────────────────────────────────────
286
+
287
+ # Extract the fully-qualified class name from source or file path
288
+ #
289
+ # @param file_path [String]
290
+ # @param source [String]
291
+ # @return [String, nil]
292
+ def extract_class_name(file_path, source)
293
+ # Build from nested module/class declarations
294
+ modules = source.scan(/^\s*(?:module|class)\s+([\w:]+)/).flatten
295
+ return nil if modules.empty?
296
+
297
+ # If first token is a fully-qualified name, use it directly
298
+ return modules.first if modules.first.include?('::')
299
+
300
+ # Otherwise join the nesting
301
+ modules.join('::')
302
+ rescue StandardError
303
+ # Fall back to convention from file path
304
+ return nil unless defined?(Rails)
305
+
306
+ file_path
307
+ .sub("#{Rails.root.join(GRAPHQL_DIRECTORY)}/", '')
308
+ .sub('.rb', '')
309
+ .camelize
310
+ end
311
+
312
+ # ──────────────────────────────────────────────────────────────────────
313
+ # Source Annotation
314
+ # ──────────────────────────────────────────────────────────────────────
315
+
316
+ # Build annotated source with a descriptive header
317
+ #
318
+ # @param source [String] Raw file contents
319
+ # @param class_name [String]
320
+ # @param unit_type [Symbol]
321
+ # @param runtime_class [Class, nil]
322
+ # @return [String]
323
+ def build_annotated_source(source, class_name, unit_type, runtime_class)
324
+ field_count = count_fields(source, runtime_class)
325
+ argument_count = count_arguments(source, runtime_class)
326
+
327
+ type_label = format_type_label(unit_type)
328
+
329
+ <<~ANNOTATION
330
+ # ╔═══════════════════════════════════════════════════════════════════════╗
331
+ # ║ #{type_label}: #{class_name.ljust(71 - type_label.length - 4)}║
332
+ # ║ Fields: #{field_count.to_s.ljust(4)} | Arguments: #{argument_count.to_s.ljust(42)}║
333
+ # ╚═══════════════════════════════════════════════════════════════════════╝
334
+
335
+ #{source}
336
+ ANNOTATION
337
+ end
338
+
339
+ # Human-readable label for unit type
340
+ #
341
+ # @param unit_type [Symbol]
342
+ # @return [String]
343
+ def format_type_label(unit_type)
344
+ case unit_type
345
+ when :graphql_mutation then 'GraphQL Mutation'
346
+ when :graphql_query then 'GraphQL Query'
347
+ when :graphql_resolver then 'GraphQL Resolver'
348
+ else 'GraphQL Type'
349
+ end
350
+ end
351
+
352
+ # ──────────────────────────────────────────────────────────────────────
353
+ # Metadata Extraction
354
+ # ──────────────────────────────────────────────────────────────────────
355
+
356
+ # Build comprehensive metadata for a GraphQL unit
357
+ #
358
+ # @param source [String]
359
+ # @param class_name [String]
360
+ # @param unit_type [Symbol]
361
+ # @param runtime_class [Class, nil]
362
+ # @return [Hash]
363
+ def build_metadata(source, _class_name, _unit_type, runtime_class)
364
+ {
365
+ # GraphQL classification
366
+ graphql_kind: detect_graphql_kind(source, runtime_class),
367
+ parent_class: extract_parent_class(source),
368
+
369
+ # Fields and arguments
370
+ fields: extract_fields(source, runtime_class),
371
+ arguments: extract_arguments(source, runtime_class),
372
+
373
+ # Interfaces and connections
374
+ interfaces: extract_interfaces(source, runtime_class),
375
+ connections: extract_connections(source),
376
+
377
+ # Resolver info
378
+ resolver_classes: extract_resolver_references(source),
379
+
380
+ # Authorization
381
+ authorization: extract_authorization(source),
382
+
383
+ # Complexity
384
+ complexity: extract_complexity(source),
385
+
386
+ # Enum values (if applicable)
387
+ enum_values: extract_enum_values(source, runtime_class),
388
+
389
+ # Union members (if applicable)
390
+ union_members: extract_union_members(source, runtime_class),
391
+
392
+ # Metrics
393
+ field_count: count_fields(source, runtime_class),
394
+ argument_count: count_arguments(source, runtime_class),
395
+ loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
396
+ }
397
+ end
398
+
399
+ # Detect what kind of GraphQL construct this is
400
+ #
401
+ # @param source [String]
402
+ # @param runtime_class [Class, nil]
403
+ # @return [Symbol]
404
+ def detect_graphql_kind(source, runtime_class)
405
+ if runtime_class
406
+ return :enum if defined?(GraphQL::Schema::Enum) && runtime_class < GraphQL::Schema::Enum
407
+ return :union if defined?(GraphQL::Schema::Union) && runtime_class < GraphQL::Schema::Union
408
+ return :input_object if defined?(GraphQL::Schema::InputObject) && runtime_class < GraphQL::Schema::InputObject
409
+ return :scalar if defined?(GraphQL::Schema::Scalar) && runtime_class < GraphQL::Schema::Scalar
410
+ return :mutation if defined?(GraphQL::Schema::Mutation) && runtime_class < GraphQL::Schema::Mutation
411
+ return :resolver if defined?(GraphQL::Schema::Resolver) && runtime_class < GraphQL::Schema::Resolver
412
+ return :interface if runtime_class.is_a?(Module) && defined?(GraphQL::Schema::Interface) && runtime_class.respond_to?(:included_modules) && runtime_class.included_modules.any? do |m|
413
+ m.name&.include?('GraphQL::Schema::Interface')
414
+ end
415
+ return :object if defined?(GraphQL::Schema::Object) && runtime_class < GraphQL::Schema::Object
416
+ end
417
+
418
+ # Fall back to source analysis
419
+ return :enum if source.match?(/< .*Enum\b/) || source.match?(/value\s+["']/)
420
+ return :union if source.match?(/< .*Union\b/) || source.match?(/possible_types\s/)
421
+ return :input_object if source.match?(/< .*InputObject\b/)
422
+ return :scalar if source.match?(/< .*Scalar\b/)
423
+ return :mutation if source.match?(/< .*(Mutation|RelayClassicMutation)\b/)
424
+ return :resolver if source.match?(/< .*Resolver\b/)
425
+ return :interface if source.match?(/include GraphQL::Schema::Interface/)
426
+
427
+ :object
428
+ end
429
+
430
+ # Extract the parent class name from source
431
+ #
432
+ # @param source [String]
433
+ # @return [String, nil]
434
+ def extract_parent_class(source)
435
+ match = source.match(/class\s+\w+\s*<\s*([\w:]+)/)
436
+ match ? match[1] : nil
437
+ end
438
+
439
+ # Extract field definitions from source and/or runtime
440
+ #
441
+ # @param source [String]
442
+ # @param runtime_class [Class, nil]
443
+ # @return [Array<Hash>]
444
+ def extract_fields(source, runtime_class)
445
+ # Prefer runtime introspection when available
446
+ if runtime_class.respond_to?(:fields) && runtime_class.fields.any?
447
+ return extract_fields_from_runtime(runtime_class)
448
+ end
449
+
450
+ extract_fields_from_source(source)
451
+ end
452
+
453
+ # Extract fields via runtime reflection
454
+ #
455
+ # @param runtime_class [Class]
456
+ # @return [Array<Hash>]
457
+ def extract_fields_from_runtime(runtime_class)
458
+ runtime_class.fields.map do |name, field|
459
+ field_hash = {
460
+ name: name,
461
+ type: field.type.to_type_signature,
462
+ description: field.description,
463
+ null: field_nullable?(field)
464
+ }
465
+
466
+ # Arguments on the field
467
+ if field.respond_to?(:arguments) && field.arguments.any?
468
+ field_hash[:arguments] = field.arguments.map do |arg_name, arg|
469
+ {
470
+ name: arg_name,
471
+ type: arg.type.to_type_signature,
472
+ required: arg.type.non_null?,
473
+ description: arg.description
474
+ }
475
+ end
476
+ end
477
+
478
+ # Resolver class
479
+ field_hash[:resolver_class] = field.resolver.name if field.respond_to?(:resolver) && field.resolver
480
+
481
+ # Complexity
482
+ field_hash[:complexity] = field.complexity if field.respond_to?(:complexity) && field.complexity
483
+
484
+ field_hash
485
+ end
486
+ rescue StandardError
487
+ extract_fields_from_source('')
488
+ end
489
+
490
+ # Check if a field is nullable
491
+ #
492
+ # @param field [GraphQL::Schema::Field]
493
+ # @return [Boolean]
494
+ def field_nullable?(field)
495
+ !field.type.non_null?
496
+ rescue StandardError
497
+ true
498
+ end
499
+
500
+ # Extract fields by parsing source text
501
+ #
502
+ # @param source [String]
503
+ # @return [Array<Hash>]
504
+ def extract_fields_from_source(source)
505
+ fields = []
506
+
507
+ # Match: field :name, Type, null: true/false, description: "..."
508
+ source.scan(/field\s+:(\w+)(?:,\s*(\S+?))?(?:,\s*(.+?))?(?:\s+do\s*$|\s*$)/m) do |name, type, rest|
509
+ field_hash = { name: name, type: type }
510
+
511
+ if rest
512
+ field_hash[:null] = !rest.include?('null: false')
513
+ desc_match = rest.match(/description:\s*["']([^"']+)["']/)
514
+ field_hash[:description] = desc_match[1] if desc_match
515
+ resolver_match = rest.match(/resolver:\s*([\w:]+)/)
516
+ field_hash[:resolver_class] = resolver_match[1] if resolver_match
517
+ end
518
+
519
+ fields << field_hash
520
+ end
521
+
522
+ fields
523
+ end
524
+
525
+ # Extract argument definitions
526
+ #
527
+ # @param source [String]
528
+ # @param runtime_class [Class, nil]
529
+ # @return [Array<Hash>]
530
+ def extract_arguments(source, runtime_class)
531
+ # Prefer runtime introspection
532
+ if runtime_class.respond_to?(:arguments) && runtime_class.arguments.any?
533
+ return extract_arguments_from_runtime(runtime_class)
534
+ end
535
+
536
+ extract_arguments_from_source(source)
537
+ end
538
+
539
+ # Extract arguments via runtime reflection
540
+ #
541
+ # @param runtime_class [Class]
542
+ # @return [Array<Hash>]
543
+ def extract_arguments_from_runtime(runtime_class)
544
+ runtime_class.arguments.map do |name, arg|
545
+ {
546
+ name: name,
547
+ type: arg.type.to_type_signature,
548
+ required: arg.type.non_null?,
549
+ description: arg.description
550
+ }
551
+ end
552
+ rescue StandardError
553
+ []
554
+ end
555
+
556
+ # Extract arguments by parsing source text
557
+ #
558
+ # @param source [String]
559
+ # @return [Array<Hash>]
560
+ def extract_arguments_from_source(source)
561
+ args = []
562
+
563
+ source.scan(/argument\s+:(\w+)(?:,\s*(\S+?))?(?:,\s*(.+?))?$/) do |name, type, rest|
564
+ arg_hash = { name: name, type: type }
565
+
566
+ if rest
567
+ arg_hash[:required] = rest.include?('required: true')
568
+ desc_match = rest.match(/description:\s*["']([^"']+)["']/)
569
+ arg_hash[:description] = desc_match[1] if desc_match
570
+ end
571
+
572
+ args << arg_hash
573
+ end
574
+
575
+ args
576
+ end
577
+
578
+ # Extract interface implementations
579
+ #
580
+ # @param source [String]
581
+ # @param runtime_class [Class, nil]
582
+ # @return [Array<String>]
583
+ def extract_interfaces(source, runtime_class)
584
+ if runtime_class.respond_to?(:interfaces) && runtime_class.interfaces.any?
585
+ return runtime_class.interfaces.filter_map(&:name)
586
+ end
587
+
588
+ source.scan(/implements\s+([\w:]+)/).flatten
589
+ rescue StandardError
590
+ source.scan(/implements\s+([\w:]+)/).flatten
591
+ end
592
+
593
+ # Extract connection type references
594
+ #
595
+ # @param source [String]
596
+ # @return [Array<String>]
597
+ def extract_connections(source)
598
+ # field :items, Types::ItemType.connection_type
599
+ connections = source.scan(/([\w:]+)\.connection_type/).flatten
600
+
601
+ # connection_type_class ConnectionType
602
+ source.scan(/connection_type_class\s+([\w:]+)/).flatten.each do |type|
603
+ connections << type
604
+ end
605
+
606
+ connections.uniq
607
+ end
608
+
609
+ # Extract references to standalone resolver classes
610
+ #
611
+ # @param source [String]
612
+ # @return [Array<String>]
613
+ def extract_resolver_references(source)
614
+ source.scan(/resolver:\s*([\w:]+)/).flatten.uniq
615
+ end
616
+
617
+ # Detect authorization patterns
618
+ #
619
+ # @param source [String]
620
+ # @return [Hash]
621
+ def extract_authorization(source)
622
+ auth = {}
623
+
624
+ auth[:has_authorized_method] = source.match?(/def\s+(?:self\.)?authorized\?/) || false
625
+ auth[:pundit] = source.match?(/PolicyFinder|policy_class|authorize!?\s/) || false
626
+ auth[:cancan] = source.match?(/can\?|authorize!\s|CanCan|Ability/) || false
627
+ auth[:custom_guard] = source.match?(/def\s+(?:self\.)?(?:visible\?|scope_items|ready\?)/) || false
628
+
629
+ auth
630
+ end
631
+
632
+ # Extract field complexity settings
633
+ #
634
+ # @param source [String]
635
+ # @return [Array<Hash>]
636
+ def extract_complexity(source)
637
+ complexities = []
638
+
639
+ source.scan(/field\s+:(\w+).*?complexity:\s*(\d+|->.*?(?:end|\}))/m) do |name, value|
640
+ complexities << { field: name, complexity: value.strip }
641
+ end
642
+
643
+ # Max complexity on schema level
644
+ if source.match?(/max_complexity\s+(\d+)/)
645
+ complexities << { field: :schema, complexity: ::Regexp.last_match(1).to_i }
646
+ end
647
+
648
+ complexities
649
+ end
650
+
651
+ # Extract enum values (for enum types)
652
+ #
653
+ # @param source [String]
654
+ # @param runtime_class [Class, nil]
655
+ # @return [Array<Hash>]
656
+ def extract_enum_values(source, runtime_class)
657
+ if runtime_class.respond_to?(:values) && runtime_class.values.is_a?(Hash)
658
+ return runtime_class.values.map do |name, value_obj|
659
+ {
660
+ name: name,
661
+ value: value_obj.respond_to?(:value) ? value_obj.value : name,
662
+ description: value_obj.respond_to?(:description) ? value_obj.description : nil
663
+ }
664
+ end
665
+ end
666
+
667
+ # Parse from source
668
+ values = []
669
+ source.scan(/value\s+["'](\w+)["'](?:.*?description:\s*["']([^"']+)["'])?/) do |name, desc|
670
+ values << { name: name, description: desc }
671
+ end
672
+
673
+ values
674
+ rescue StandardError
675
+ []
676
+ end
677
+
678
+ # Extract union member types
679
+ #
680
+ # @param source [String]
681
+ # @param runtime_class [Class, nil]
682
+ # @return [Array<String>]
683
+ def extract_union_members(source, runtime_class)
684
+ if runtime_class.respond_to?(:possible_types) && runtime_class.possible_types.any?
685
+ return runtime_class.possible_types.filter_map(&:name)
686
+ end
687
+
688
+ source.scan(/possible_types\s+(.+)$/).flatten.flat_map do |types_str|
689
+ types_str.scan(/([\w:]+)/).flatten
690
+ end
691
+ rescue StandardError
692
+ []
693
+ end
694
+
695
+ # ──────────────────────────────────────────────────────────────────────
696
+ # Field Counting Helpers
697
+ # ──────────────────────────────────────────────────────────────────────
698
+
699
+ # Count fields from runtime or source
700
+ #
701
+ # @param source [String]
702
+ # @param runtime_class [Class, nil]
703
+ # @return [Integer]
704
+ def count_fields(source, runtime_class)
705
+ if runtime_class.respond_to?(:fields)
706
+ runtime_class.fields.size
707
+ else
708
+ source.scan(/^\s*field\s+:/).size
709
+ end
710
+ rescue StandardError
711
+ source.scan(/^\s*field\s+:/).size
712
+ end
713
+
714
+ # Count arguments from runtime or source
715
+ #
716
+ # @param source [String]
717
+ # @param runtime_class [Class, nil]
718
+ # @return [Integer]
719
+ def count_arguments(source, runtime_class)
720
+ if runtime_class.respond_to?(:arguments)
721
+ runtime_class.arguments.size
722
+ else
723
+ source.scan(/^\s*argument\s+:/).size
724
+ end
725
+ rescue StandardError
726
+ source.scan(/^\s*argument\s+:/).size
727
+ end
728
+
729
+ # ──────────────────────────────────────────────────────────────────────
730
+ # Dependency Extraction
731
+ # ──────────────────────────────────────────────────────────────────────
732
+
733
+ # Extract all dependencies from source text
734
+ #
735
+ # Uses pattern scanning (not AR descendant iteration) to avoid O(n^2).
736
+ #
737
+ # @param source [String]
738
+ # @return [Array<Hash>]
739
+ def extract_dependencies(source, identifier = nil)
740
+ # Other GraphQL type references (Types::*), excluding self-references
741
+ deps = source.scan(/Types::\w+/).uniq.filter_map do |type_ref|
742
+ next if type_ref == identifier
743
+
744
+ { type: :graphql_type, target: type_ref, via: :type_reference }
745
+ end
746
+
747
+ # Model references: scan for capitalized constants that look like model names.
748
+ # GraphQL uses its own pattern (not ModelNameCache) to avoid O(n^2).
749
+ source.scan(/\b([A-Z][a-z]\w*)\.(?:find|where|find_by|create|new|first|last|all|count|exists\?|destroy|update|pluck|select|order|limit|includes|joins|preload|eager_load)\b/).flatten.uniq.each do |model_ref|
750
+ deps << { type: :model, target: model_ref, via: :code_reference }
751
+ end
752
+
753
+ source.scan(/\b([A-Z][a-z][a-zA-Z]*)\b/).flatten.uniq.each do |const_ref|
754
+ if const_ref.match?(/\A(Types|Mutations|Resolvers|GraphQL|Base|String|Integer|Float|Boolean|Array|Hash|Set|Struct|Module|Class|Object|ID|Int|ISO8601)\z/)
755
+ next
756
+ end
757
+ next if deps.any? { |d| d[:target] == const_ref }
758
+
759
+ if source.match?(/\b#{Regexp.escape(const_ref)}\.(?:find|where|find_by|create|new|first|last|all)\b/)
760
+ deps << { type: :model, target: const_ref, via: :code_reference }
761
+ end
762
+ end
763
+
764
+ deps.concat(scan_service_dependencies(source))
765
+ deps.concat(scan_job_dependencies(source))
766
+ deps.concat(scan_mailer_dependencies(source))
767
+
768
+ # Resolver dependencies (standalone resolver classes referenced in fields)
769
+ source.scan(/resolver:\s*([\w:]+)/).flatten.uniq.each do |resolver|
770
+ deps << { type: :graphql_resolver, target: resolver, via: :field_resolver }
771
+ end
772
+
773
+ deps.uniq { |d| [d[:type], d[:target]] }
774
+ end
775
+
776
+ # ──────────────────────────────────────────────────────────────────────
777
+ # Chunking
778
+ # ──────────────────────────────────────────────────────────────────────
779
+
780
+ # Build semantic chunks for large GraphQL types
781
+ #
782
+ # @param unit [ExtractedUnit]
783
+ # @param runtime_class [Class, nil]
784
+ # @return [Array<Hash>]
785
+ def build_chunks(unit, _runtime_class)
786
+ chunks = []
787
+
788
+ # Summary chunk: overview with field list
789
+ chunks << build_summary_chunk(unit)
790
+
791
+ # Field-group chunks for types with many fields
792
+ fields = unit.metadata[:fields] || []
793
+ if fields.size > 10
794
+ fields.each_slice(10).with_index do |field_group, idx|
795
+ chunks << build_field_group_chunk(unit, field_group, idx)
796
+ end
797
+ end
798
+
799
+ # Arguments chunk for mutations/resolvers
800
+ arguments = unit.metadata[:arguments] || []
801
+ chunks << build_arguments_chunk(unit, arguments) if arguments.any?
802
+
803
+ chunks
804
+ end
805
+
806
+ # Build a summary chunk with high-level type information
807
+ #
808
+ # @param unit [ExtractedUnit]
809
+ # @return [Hash]
810
+ def build_summary_chunk(unit)
811
+ meta = unit.metadata
812
+ fields = meta[:fields] || []
813
+ field_names = fields.map { |f| f[:name] }.compact
814
+
815
+ interfaces = meta[:interfaces] || []
816
+ auth = meta[:authorization] || {}
817
+
818
+ auth_summary = []
819
+ auth_summary << 'authorized?' if auth[:has_authorized_method]
820
+ auth_summary << 'pundit' if auth[:pundit]
821
+ auth_summary << 'cancan' if auth[:cancan]
822
+
823
+ {
824
+ chunk_type: :summary,
825
+ identifier: "#{unit.identifier}:summary",
826
+ content: <<~SUMMARY,
827
+ # #{unit.identifier} - #{format_type_label(unit.type)} Summary
828
+
829
+ Kind: #{meta[:graphql_kind]}
830
+ Parent: #{meta[:parent_class] || 'unknown'}
831
+ Fields: #{field_names.join(', ').presence || 'none'}
832
+ Interfaces: #{interfaces.join(', ').presence || 'none'}
833
+ Authorization: #{auth_summary.join(', ').presence || 'none'}
834
+ SUMMARY
835
+ metadata: { parent: unit.identifier, purpose: :overview }
836
+ }
837
+ end
838
+
839
+ # Build a chunk for a group of fields
840
+ #
841
+ # @param unit [ExtractedUnit]
842
+ # @param field_group [Array<Hash>]
843
+ # @param group_index [Integer]
844
+ # @return [Hash]
845
+ def build_field_group_chunk(unit, field_group, group_index)
846
+ lines = field_group.map do |f|
847
+ parts = ["field :#{f[:name]}"]
848
+ parts << f[:type] if f[:type]
849
+ parts << "(#{f[:description]})" if f[:description]
850
+ parts.join(', ')
851
+ end
852
+
853
+ {
854
+ chunk_type: :fields,
855
+ identifier: "#{unit.identifier}:fields_#{group_index}",
856
+ content: <<~FIELDS,
857
+ # #{unit.identifier} - Fields (group #{group_index})
858
+
859
+ #{lines.join("\n")}
860
+ FIELDS
861
+ metadata: { parent: unit.identifier, purpose: :fields, group_index: group_index }
862
+ }
863
+ end
864
+
865
+ # Build a chunk for arguments
866
+ #
867
+ # @param unit [ExtractedUnit]
868
+ # @param arguments [Array<Hash>]
869
+ # @return [Hash]
870
+ def build_arguments_chunk(unit, arguments)
871
+ lines = arguments.map do |a|
872
+ parts = ["argument :#{a[:name]}"]
873
+ parts << a[:type] if a[:type]
874
+ parts << 'required' if a[:required]
875
+ parts << "(#{a[:description]})" if a[:description]
876
+ parts.join(', ')
877
+ end
878
+
879
+ {
880
+ chunk_type: :arguments,
881
+ identifier: "#{unit.identifier}:arguments",
882
+ content: <<~ARGS,
883
+ # #{unit.identifier} - Arguments
884
+
885
+ #{lines.join("\n")}
886
+ ARGS
887
+ metadata: { parent: unit.identifier, purpose: :arguments }
888
+ }
889
+ end
890
+ end
891
+ end
892
+ end