codebase_index 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +29 -0
- data/CODE_OF_CONDUCT.md +83 -0
- data/CONTRIBUTING.md +65 -0
- data/LICENSE.txt +21 -0
- data/README.md +481 -0
- data/exe/codebase-console-mcp +22 -0
- data/exe/codebase-index-mcp +61 -0
- data/exe/codebase-index-mcp-http +64 -0
- data/exe/codebase-index-mcp-start +58 -0
- data/lib/codebase_index/ast/call_site_extractor.rb +106 -0
- data/lib/codebase_index/ast/method_extractor.rb +76 -0
- data/lib/codebase_index/ast/node.rb +88 -0
- data/lib/codebase_index/ast/parser.rb +653 -0
- data/lib/codebase_index/ast.rb +6 -0
- data/lib/codebase_index/builder.rb +137 -0
- data/lib/codebase_index/chunking/chunk.rb +84 -0
- data/lib/codebase_index/chunking/semantic_chunker.rb +290 -0
- data/lib/codebase_index/console/adapters/cache_adapter.rb +58 -0
- data/lib/codebase_index/console/adapters/good_job_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/sidekiq_adapter.rb +66 -0
- data/lib/codebase_index/console/adapters/solid_queue_adapter.rb +66 -0
- data/lib/codebase_index/console/audit_logger.rb +75 -0
- data/lib/codebase_index/console/bridge.rb +170 -0
- data/lib/codebase_index/console/confirmation.rb +90 -0
- data/lib/codebase_index/console/connection_manager.rb +173 -0
- data/lib/codebase_index/console/console_response_renderer.rb +78 -0
- data/lib/codebase_index/console/model_validator.rb +81 -0
- data/lib/codebase_index/console/safe_context.rb +82 -0
- data/lib/codebase_index/console/server.rb +557 -0
- data/lib/codebase_index/console/sql_validator.rb +172 -0
- data/lib/codebase_index/console/tools/tier1.rb +118 -0
- data/lib/codebase_index/console/tools/tier2.rb +117 -0
- data/lib/codebase_index/console/tools/tier3.rb +110 -0
- data/lib/codebase_index/console/tools/tier4.rb +79 -0
- data/lib/codebase_index/coordination/pipeline_lock.rb +109 -0
- data/lib/codebase_index/cost_model/embedding_cost.rb +88 -0
- data/lib/codebase_index/cost_model/estimator.rb +128 -0
- data/lib/codebase_index/cost_model/provider_pricing.rb +67 -0
- data/lib/codebase_index/cost_model/storage_cost.rb +52 -0
- data/lib/codebase_index/cost_model.rb +22 -0
- data/lib/codebase_index/db/migrations/001_create_units.rb +38 -0
- data/lib/codebase_index/db/migrations/002_create_edges.rb +35 -0
- data/lib/codebase_index/db/migrations/003_create_embeddings.rb +37 -0
- data/lib/codebase_index/db/migrations/004_create_snapshots.rb +45 -0
- data/lib/codebase_index/db/migrations/005_create_snapshot_units.rb +40 -0
- data/lib/codebase_index/db/migrator.rb +71 -0
- data/lib/codebase_index/db/schema_version.rb +73 -0
- data/lib/codebase_index/dependency_graph.rb +227 -0
- data/lib/codebase_index/embedding/indexer.rb +130 -0
- data/lib/codebase_index/embedding/openai.rb +105 -0
- data/lib/codebase_index/embedding/provider.rb +135 -0
- data/lib/codebase_index/embedding/text_preparer.rb +112 -0
- data/lib/codebase_index/evaluation/baseline_runner.rb +115 -0
- data/lib/codebase_index/evaluation/evaluator.rb +146 -0
- data/lib/codebase_index/evaluation/metrics.rb +79 -0
- data/lib/codebase_index/evaluation/query_set.rb +148 -0
- data/lib/codebase_index/evaluation/report_generator.rb +90 -0
- data/lib/codebase_index/extracted_unit.rb +145 -0
- data/lib/codebase_index/extractor.rb +956 -0
- data/lib/codebase_index/extractors/action_cable_extractor.rb +228 -0
- data/lib/codebase_index/extractors/ast_source_extraction.rb +46 -0
- data/lib/codebase_index/extractors/behavioral_profile.rb +309 -0
- data/lib/codebase_index/extractors/caching_extractor.rb +261 -0
- data/lib/codebase_index/extractors/callback_analyzer.rb +232 -0
- data/lib/codebase_index/extractors/concern_extractor.rb +253 -0
- data/lib/codebase_index/extractors/configuration_extractor.rb +219 -0
- data/lib/codebase_index/extractors/controller_extractor.rb +494 -0
- data/lib/codebase_index/extractors/database_view_extractor.rb +278 -0
- data/lib/codebase_index/extractors/decorator_extractor.rb +260 -0
- data/lib/codebase_index/extractors/engine_extractor.rb +204 -0
- data/lib/codebase_index/extractors/event_extractor.rb +211 -0
- data/lib/codebase_index/extractors/factory_extractor.rb +289 -0
- data/lib/codebase_index/extractors/graphql_extractor.rb +917 -0
- data/lib/codebase_index/extractors/i18n_extractor.rb +117 -0
- data/lib/codebase_index/extractors/job_extractor.rb +369 -0
- data/lib/codebase_index/extractors/lib_extractor.rb +249 -0
- data/lib/codebase_index/extractors/mailer_extractor.rb +339 -0
- data/lib/codebase_index/extractors/manager_extractor.rb +202 -0
- data/lib/codebase_index/extractors/middleware_extractor.rb +133 -0
- data/lib/codebase_index/extractors/migration_extractor.rb +469 -0
- data/lib/codebase_index/extractors/model_extractor.rb +960 -0
- data/lib/codebase_index/extractors/phlex_extractor.rb +252 -0
- data/lib/codebase_index/extractors/policy_extractor.rb +214 -0
- data/lib/codebase_index/extractors/poro_extractor.rb +246 -0
- data/lib/codebase_index/extractors/pundit_extractor.rb +223 -0
- data/lib/codebase_index/extractors/rails_source_extractor.rb +473 -0
- data/lib/codebase_index/extractors/rake_task_extractor.rb +343 -0
- data/lib/codebase_index/extractors/route_extractor.rb +181 -0
- data/lib/codebase_index/extractors/scheduled_job_extractor.rb +331 -0
- data/lib/codebase_index/extractors/serializer_extractor.rb +334 -0
- data/lib/codebase_index/extractors/service_extractor.rb +254 -0
- data/lib/codebase_index/extractors/shared_dependency_scanner.rb +91 -0
- data/lib/codebase_index/extractors/shared_utility_methods.rb +99 -0
- data/lib/codebase_index/extractors/state_machine_extractor.rb +398 -0
- data/lib/codebase_index/extractors/test_mapping_extractor.rb +225 -0
- data/lib/codebase_index/extractors/validator_extractor.rb +225 -0
- data/lib/codebase_index/extractors/view_component_extractor.rb +310 -0
- data/lib/codebase_index/extractors/view_template_extractor.rb +261 -0
- data/lib/codebase_index/feedback/gap_detector.rb +89 -0
- data/lib/codebase_index/feedback/store.rb +119 -0
- data/lib/codebase_index/flow_analysis/operation_extractor.rb +209 -0
- data/lib/codebase_index/flow_analysis/response_code_mapper.rb +154 -0
- data/lib/codebase_index/flow_assembler.rb +290 -0
- data/lib/codebase_index/flow_document.rb +191 -0
- data/lib/codebase_index/flow_precomputer.rb +102 -0
- data/lib/codebase_index/formatting/base.rb +40 -0
- data/lib/codebase_index/formatting/claude_adapter.rb +98 -0
- data/lib/codebase_index/formatting/generic_adapter.rb +56 -0
- data/lib/codebase_index/formatting/gpt_adapter.rb +64 -0
- data/lib/codebase_index/formatting/human_adapter.rb +78 -0
- data/lib/codebase_index/graph_analyzer.rb +374 -0
- data/lib/codebase_index/mcp/index_reader.rb +394 -0
- data/lib/codebase_index/mcp/renderers/claude_renderer.rb +81 -0
- data/lib/codebase_index/mcp/renderers/json_renderer.rb +17 -0
- data/lib/codebase_index/mcp/renderers/markdown_renderer.rb +352 -0
- data/lib/codebase_index/mcp/renderers/plain_renderer.rb +240 -0
- data/lib/codebase_index/mcp/server.rb +935 -0
- data/lib/codebase_index/mcp/tool_response_renderer.rb +62 -0
- data/lib/codebase_index/model_name_cache.rb +51 -0
- data/lib/codebase_index/notion/client.rb +217 -0
- data/lib/codebase_index/notion/exporter.rb +219 -0
- data/lib/codebase_index/notion/mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/column_mapper.rb +65 -0
- data/lib/codebase_index/notion/mappers/migration_mapper.rb +39 -0
- data/lib/codebase_index/notion/mappers/model_mapper.rb +164 -0
- data/lib/codebase_index/notion/rate_limiter.rb +68 -0
- data/lib/codebase_index/observability/health_check.rb +81 -0
- data/lib/codebase_index/observability/instrumentation.rb +34 -0
- data/lib/codebase_index/observability/structured_logger.rb +75 -0
- data/lib/codebase_index/operator/error_escalator.rb +81 -0
- data/lib/codebase_index/operator/pipeline_guard.rb +99 -0
- data/lib/codebase_index/operator/status_reporter.rb +80 -0
- data/lib/codebase_index/railtie.rb +26 -0
- data/lib/codebase_index/resilience/circuit_breaker.rb +99 -0
- data/lib/codebase_index/resilience/index_validator.rb +185 -0
- data/lib/codebase_index/resilience/retryable_provider.rb +108 -0
- data/lib/codebase_index/retrieval/context_assembler.rb +249 -0
- data/lib/codebase_index/retrieval/query_classifier.rb +131 -0
- data/lib/codebase_index/retrieval/ranker.rb +273 -0
- data/lib/codebase_index/retrieval/search_executor.rb +327 -0
- data/lib/codebase_index/retriever.rb +160 -0
- data/lib/codebase_index/ruby_analyzer/class_analyzer.rb +190 -0
- data/lib/codebase_index/ruby_analyzer/dataflow_analyzer.rb +78 -0
- data/lib/codebase_index/ruby_analyzer/fqn_builder.rb +18 -0
- data/lib/codebase_index/ruby_analyzer/mermaid_renderer.rb +275 -0
- data/lib/codebase_index/ruby_analyzer/method_analyzer.rb +143 -0
- data/lib/codebase_index/ruby_analyzer/trace_enricher.rb +139 -0
- data/lib/codebase_index/ruby_analyzer.rb +87 -0
- data/lib/codebase_index/session_tracer/file_store.rb +111 -0
- data/lib/codebase_index/session_tracer/middleware.rb +143 -0
- data/lib/codebase_index/session_tracer/redis_store.rb +112 -0
- data/lib/codebase_index/session_tracer/session_flow_assembler.rb +263 -0
- data/lib/codebase_index/session_tracer/session_flow_document.rb +223 -0
- data/lib/codebase_index/session_tracer/solid_cache_store.rb +145 -0
- data/lib/codebase_index/session_tracer/store.rb +67 -0
- data/lib/codebase_index/storage/graph_store.rb +120 -0
- data/lib/codebase_index/storage/metadata_store.rb +169 -0
- data/lib/codebase_index/storage/pgvector.rb +163 -0
- data/lib/codebase_index/storage/qdrant.rb +172 -0
- data/lib/codebase_index/storage/vector_store.rb +156 -0
- data/lib/codebase_index/temporal/snapshot_store.rb +341 -0
- data/lib/codebase_index/version.rb +5 -0
- data/lib/codebase_index.rb +223 -0
- data/lib/generators/codebase_index/install_generator.rb +32 -0
- data/lib/generators/codebase_index/pgvector_generator.rb +37 -0
- data/lib/generators/codebase_index/templates/add_pgvector_to_codebase_index.rb.erb +15 -0
- data/lib/generators/codebase_index/templates/create_codebase_index_tables.rb.erb +43 -0
- data/lib/tasks/codebase_index.rake +583 -0
- data/lib/tasks/codebase_index_evaluation.rake +115 -0
- metadata +252 -0
|
@@ -0,0 +1,917 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'shared_utility_methods'
|
|
4
|
+
require_relative 'shared_dependency_scanner'
|
|
5
|
+
|
|
6
|
+
module CodebaseIndex
|
|
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
|
+
# Uses a multi-tier strategy matching the model extractor's pattern.
|
|
206
|
+
#
|
|
207
|
+
# @param klass [Class]
|
|
208
|
+
# @return [String] Absolute path to the source file
|
|
209
|
+
def source_file_for_class(klass)
|
|
210
|
+
app_root = Rails.root.to_s
|
|
211
|
+
convention_path = Rails.root.join("#{GRAPHQL_DIRECTORY}/#{klass.name.underscore}.rb").to_s
|
|
212
|
+
|
|
213
|
+
# Tier 1: Instance methods defined directly on this class
|
|
214
|
+
klass.instance_methods(false).each do |method_name|
|
|
215
|
+
loc = klass.instance_method(method_name).source_location&.first
|
|
216
|
+
return loc if loc&.start_with?(app_root)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Tier 2: Singleton methods defined on this class
|
|
220
|
+
klass.singleton_methods(false).each do |method_name|
|
|
221
|
+
loc = klass.method(method_name).source_location&.first
|
|
222
|
+
return loc if loc&.start_with?(app_root)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Tier 3: Convention path if file exists
|
|
226
|
+
return convention_path if File.exist?(convention_path)
|
|
227
|
+
|
|
228
|
+
# Tier 4: const_source_location (Ruby 3.0+)
|
|
229
|
+
if Object.respond_to?(:const_source_location)
|
|
230
|
+
loc = Object.const_source_location(klass.name)&.first
|
|
231
|
+
return loc if loc&.start_with?(app_root)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Tier 5: Always return convention path — never a gem path
|
|
235
|
+
convention_path
|
|
236
|
+
rescue StandardError
|
|
237
|
+
Rails.root.join("#{GRAPHQL_DIRECTORY}/#{klass.name.underscore}.rb").to_s
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
241
|
+
# Classification
|
|
242
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
# Classify unit type from a runtime type class
|
|
245
|
+
#
|
|
246
|
+
# @param type_class [Class]
|
|
247
|
+
# @return [Symbol]
|
|
248
|
+
def classify_runtime_type(type_class)
|
|
249
|
+
if defined?(GraphQL::Schema::Mutation) && type_class < GraphQL::Schema::Mutation
|
|
250
|
+
:graphql_mutation
|
|
251
|
+
elsif defined?(GraphQL::Schema::Resolver) && type_class < GraphQL::Schema::Resolver
|
|
252
|
+
:graphql_resolver
|
|
253
|
+
elsif defined?(GraphQL::Schema::Enum) && type_class < GraphQL::Schema::Enum
|
|
254
|
+
:graphql_type
|
|
255
|
+
elsif defined?(GraphQL::Schema::Union) && type_class < GraphQL::Schema::Union
|
|
256
|
+
:graphql_type
|
|
257
|
+
elsif defined?(GraphQL::Schema::Interface) && type_class.is_a?(Module) && type_class.respond_to?(:fields)
|
|
258
|
+
:graphql_type
|
|
259
|
+
elsif defined?(GraphQL::Schema::InputObject) && type_class < GraphQL::Schema::InputObject
|
|
260
|
+
:graphql_type
|
|
261
|
+
elsif defined?(GraphQL::Schema::Scalar) && type_class < GraphQL::Schema::Scalar
|
|
262
|
+
:graphql_type
|
|
263
|
+
elsif defined?(GraphQL::Schema::Object) && type_class < GraphQL::Schema::Object
|
|
264
|
+
# Check if this is the Query root type
|
|
265
|
+
if @schema_class.respond_to?(:query) && @schema_class.query == type_class
|
|
266
|
+
:graphql_query
|
|
267
|
+
else
|
|
268
|
+
:graphql_type
|
|
269
|
+
end
|
|
270
|
+
else
|
|
271
|
+
:graphql_type
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Classify unit type from file path and source content
|
|
276
|
+
#
|
|
277
|
+
# @param file_path [String]
|
|
278
|
+
# @param source [String]
|
|
279
|
+
# @return [Symbol]
|
|
280
|
+
def classify_unit_type(file_path, source)
|
|
281
|
+
return :graphql_mutation if file_path.include?('/mutations/')
|
|
282
|
+
return :graphql_resolver if file_path.include?('/resolvers/')
|
|
283
|
+
|
|
284
|
+
return :graphql_mutation if source.match?(/< (GraphQL::Schema::Mutation|Mutations::Base|BaseMutation)/)
|
|
285
|
+
|
|
286
|
+
return :graphql_resolver if source.match?(/< (GraphQL::Schema::Resolver|Resolvers::Base|BaseResolver)/)
|
|
287
|
+
|
|
288
|
+
# Query type is usually the root query object
|
|
289
|
+
return :graphql_query if file_path.match?(/query_type\.rb$/) || source.match?(/class QueryType\b/)
|
|
290
|
+
|
|
291
|
+
:graphql_type
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Check if a source file contains a graphql-ruby class
|
|
295
|
+
#
|
|
296
|
+
# @param source [String]
|
|
297
|
+
# @return [Boolean]
|
|
298
|
+
def graphql_class?(source)
|
|
299
|
+
source.match?(/< GraphQL::Schema::(Object|InputObject|Enum|Union|Scalar|Mutation|Resolver|Interface|RelayClassicMutation)/) ||
|
|
300
|
+
source.match?(/< (Types::Base\w+|Base(Type|InputObject|Enum|Union|Scalar|Mutation|Resolver|Interface))/) ||
|
|
301
|
+
source.match?(/< (Mutations::Base|Resolvers::Base)/) ||
|
|
302
|
+
source.match?(/include GraphQL::Schema::Interface/) ||
|
|
303
|
+
(source.include?('field :') && source.match?(/< .*Type\b/))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
307
|
+
# Class Name and Namespace
|
|
308
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
# Extract the fully-qualified class name from source or file path
|
|
311
|
+
#
|
|
312
|
+
# @param file_path [String]
|
|
313
|
+
# @param source [String]
|
|
314
|
+
# @return [String, nil]
|
|
315
|
+
def extract_class_name(file_path, source)
|
|
316
|
+
# Build from nested module/class declarations
|
|
317
|
+
modules = source.scan(/^\s*(?:module|class)\s+([\w:]+)/).flatten
|
|
318
|
+
return nil if modules.empty?
|
|
319
|
+
|
|
320
|
+
# If first token is a fully-qualified name, use it directly
|
|
321
|
+
return modules.first if modules.first.include?('::')
|
|
322
|
+
|
|
323
|
+
# Otherwise join the nesting
|
|
324
|
+
modules.join('::')
|
|
325
|
+
rescue StandardError
|
|
326
|
+
# Fall back to convention from file path
|
|
327
|
+
return nil unless defined?(Rails)
|
|
328
|
+
|
|
329
|
+
file_path
|
|
330
|
+
.sub("#{Rails.root.join(GRAPHQL_DIRECTORY)}/", '')
|
|
331
|
+
.sub('.rb', '')
|
|
332
|
+
.camelize
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
336
|
+
# Source Annotation
|
|
337
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
# Build annotated source with a descriptive header
|
|
340
|
+
#
|
|
341
|
+
# @param source [String] Raw file contents
|
|
342
|
+
# @param class_name [String]
|
|
343
|
+
# @param unit_type [Symbol]
|
|
344
|
+
# @param runtime_class [Class, nil]
|
|
345
|
+
# @return [String]
|
|
346
|
+
def build_annotated_source(source, class_name, unit_type, runtime_class)
|
|
347
|
+
field_count = count_fields(source, runtime_class)
|
|
348
|
+
argument_count = count_arguments(source, runtime_class)
|
|
349
|
+
|
|
350
|
+
type_label = format_type_label(unit_type)
|
|
351
|
+
|
|
352
|
+
<<~ANNOTATION
|
|
353
|
+
# ╔═══════════════════════════════════════════════════════════════════════╗
|
|
354
|
+
# ║ #{type_label}: #{class_name.ljust(71 - type_label.length - 4)}║
|
|
355
|
+
# ║ Fields: #{field_count.to_s.ljust(4)} | Arguments: #{argument_count.to_s.ljust(42)}║
|
|
356
|
+
# ╚═══════════════════════════════════════════════════════════════════════╝
|
|
357
|
+
|
|
358
|
+
#{source}
|
|
359
|
+
ANNOTATION
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Human-readable label for unit type
|
|
363
|
+
#
|
|
364
|
+
# @param unit_type [Symbol]
|
|
365
|
+
# @return [String]
|
|
366
|
+
def format_type_label(unit_type)
|
|
367
|
+
case unit_type
|
|
368
|
+
when :graphql_mutation then 'GraphQL Mutation'
|
|
369
|
+
when :graphql_query then 'GraphQL Query'
|
|
370
|
+
when :graphql_resolver then 'GraphQL Resolver'
|
|
371
|
+
else 'GraphQL Type'
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
376
|
+
# Metadata Extraction
|
|
377
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
# Build comprehensive metadata for a GraphQL unit
|
|
380
|
+
#
|
|
381
|
+
# @param source [String]
|
|
382
|
+
# @param class_name [String]
|
|
383
|
+
# @param unit_type [Symbol]
|
|
384
|
+
# @param runtime_class [Class, nil]
|
|
385
|
+
# @return [Hash]
|
|
386
|
+
def build_metadata(source, _class_name, _unit_type, runtime_class)
|
|
387
|
+
{
|
|
388
|
+
# GraphQL classification
|
|
389
|
+
graphql_kind: detect_graphql_kind(source, runtime_class),
|
|
390
|
+
parent_class: extract_parent_class(source),
|
|
391
|
+
|
|
392
|
+
# Fields and arguments
|
|
393
|
+
fields: extract_fields(source, runtime_class),
|
|
394
|
+
arguments: extract_arguments(source, runtime_class),
|
|
395
|
+
|
|
396
|
+
# Interfaces and connections
|
|
397
|
+
interfaces: extract_interfaces(source, runtime_class),
|
|
398
|
+
connections: extract_connections(source),
|
|
399
|
+
|
|
400
|
+
# Resolver info
|
|
401
|
+
resolver_classes: extract_resolver_references(source),
|
|
402
|
+
|
|
403
|
+
# Authorization
|
|
404
|
+
authorization: extract_authorization(source),
|
|
405
|
+
|
|
406
|
+
# Complexity
|
|
407
|
+
complexity: extract_complexity(source),
|
|
408
|
+
|
|
409
|
+
# Enum values (if applicable)
|
|
410
|
+
enum_values: extract_enum_values(source, runtime_class),
|
|
411
|
+
|
|
412
|
+
# Union members (if applicable)
|
|
413
|
+
union_members: extract_union_members(source, runtime_class),
|
|
414
|
+
|
|
415
|
+
# Metrics
|
|
416
|
+
field_count: count_fields(source, runtime_class),
|
|
417
|
+
argument_count: count_arguments(source, runtime_class),
|
|
418
|
+
loc: source.lines.count { |l| l.strip.length.positive? && !l.strip.start_with?('#') }
|
|
419
|
+
}
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Detect what kind of GraphQL construct this is
|
|
423
|
+
#
|
|
424
|
+
# @param source [String]
|
|
425
|
+
# @param runtime_class [Class, nil]
|
|
426
|
+
# @return [Symbol]
|
|
427
|
+
def detect_graphql_kind(source, runtime_class)
|
|
428
|
+
if runtime_class
|
|
429
|
+
return :enum if defined?(GraphQL::Schema::Enum) && runtime_class < GraphQL::Schema::Enum
|
|
430
|
+
return :union if defined?(GraphQL::Schema::Union) && runtime_class < GraphQL::Schema::Union
|
|
431
|
+
return :input_object if defined?(GraphQL::Schema::InputObject) && runtime_class < GraphQL::Schema::InputObject
|
|
432
|
+
return :scalar if defined?(GraphQL::Schema::Scalar) && runtime_class < GraphQL::Schema::Scalar
|
|
433
|
+
return :mutation if defined?(GraphQL::Schema::Mutation) && runtime_class < GraphQL::Schema::Mutation
|
|
434
|
+
return :resolver if defined?(GraphQL::Schema::Resolver) && runtime_class < GraphQL::Schema::Resolver
|
|
435
|
+
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|
|
|
436
|
+
m.name&.include?('GraphQL::Schema::Interface')
|
|
437
|
+
end
|
|
438
|
+
return :object if defined?(GraphQL::Schema::Object) && runtime_class < GraphQL::Schema::Object
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Fall back to source analysis
|
|
442
|
+
return :enum if source.match?(/< .*Enum\b/) || source.match?(/value\s+["']/)
|
|
443
|
+
return :union if source.match?(/< .*Union\b/) || source.match?(/possible_types\s/)
|
|
444
|
+
return :input_object if source.match?(/< .*InputObject\b/)
|
|
445
|
+
return :scalar if source.match?(/< .*Scalar\b/)
|
|
446
|
+
return :mutation if source.match?(/< .*(Mutation|RelayClassicMutation)\b/)
|
|
447
|
+
return :resolver if source.match?(/< .*Resolver\b/)
|
|
448
|
+
return :interface if source.match?(/include GraphQL::Schema::Interface/)
|
|
449
|
+
|
|
450
|
+
:object
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
# Extract the parent class name from source
|
|
454
|
+
#
|
|
455
|
+
# @param source [String]
|
|
456
|
+
# @return [String, nil]
|
|
457
|
+
def extract_parent_class(source)
|
|
458
|
+
match = source.match(/class\s+\w+\s*<\s*([\w:]+)/)
|
|
459
|
+
match ? match[1] : nil
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Extract field definitions from source and/or runtime
|
|
463
|
+
#
|
|
464
|
+
# @param source [String]
|
|
465
|
+
# @param runtime_class [Class, nil]
|
|
466
|
+
# @return [Array<Hash>]
|
|
467
|
+
def extract_fields(source, runtime_class)
|
|
468
|
+
# Prefer runtime introspection when available
|
|
469
|
+
if runtime_class.respond_to?(:fields) && runtime_class.fields.any?
|
|
470
|
+
return extract_fields_from_runtime(runtime_class)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
extract_fields_from_source(source)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Extract fields via runtime reflection
|
|
477
|
+
#
|
|
478
|
+
# @param runtime_class [Class]
|
|
479
|
+
# @return [Array<Hash>]
|
|
480
|
+
def extract_fields_from_runtime(runtime_class)
|
|
481
|
+
runtime_class.fields.map do |name, field|
|
|
482
|
+
field_hash = {
|
|
483
|
+
name: name,
|
|
484
|
+
type: field.type.to_type_signature,
|
|
485
|
+
description: field.description,
|
|
486
|
+
null: field_nullable?(field)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
# Arguments on the field
|
|
490
|
+
if field.respond_to?(:arguments) && field.arguments.any?
|
|
491
|
+
field_hash[:arguments] = field.arguments.map do |arg_name, arg|
|
|
492
|
+
{
|
|
493
|
+
name: arg_name,
|
|
494
|
+
type: arg.type.to_type_signature,
|
|
495
|
+
required: arg.type.non_null?,
|
|
496
|
+
description: arg.description
|
|
497
|
+
}
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Resolver class
|
|
502
|
+
field_hash[:resolver_class] = field.resolver.name if field.respond_to?(:resolver) && field.resolver
|
|
503
|
+
|
|
504
|
+
# Complexity
|
|
505
|
+
field_hash[:complexity] = field.complexity if field.respond_to?(:complexity) && field.complexity
|
|
506
|
+
|
|
507
|
+
field_hash
|
|
508
|
+
end
|
|
509
|
+
rescue StandardError
|
|
510
|
+
extract_fields_from_source('')
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Check if a field is nullable
|
|
514
|
+
#
|
|
515
|
+
# @param field [GraphQL::Schema::Field]
|
|
516
|
+
# @return [Boolean]
|
|
517
|
+
def field_nullable?(field)
|
|
518
|
+
!field.type.non_null?
|
|
519
|
+
rescue StandardError
|
|
520
|
+
true
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# Extract fields by parsing source text
|
|
524
|
+
#
|
|
525
|
+
# @param source [String]
|
|
526
|
+
# @return [Array<Hash>]
|
|
527
|
+
def extract_fields_from_source(source)
|
|
528
|
+
fields = []
|
|
529
|
+
|
|
530
|
+
# Match: field :name, Type, null: true/false, description: "..."
|
|
531
|
+
source.scan(/field\s+:(\w+)(?:,\s*(\S+?))?(?:,\s*(.+?))?(?:\s+do\s*$|\s*$)/m) do |name, type, rest|
|
|
532
|
+
field_hash = { name: name, type: type }
|
|
533
|
+
|
|
534
|
+
if rest
|
|
535
|
+
field_hash[:null] = !rest.include?('null: false')
|
|
536
|
+
desc_match = rest.match(/description:\s*["']([^"']+)["']/)
|
|
537
|
+
field_hash[:description] = desc_match[1] if desc_match
|
|
538
|
+
resolver_match = rest.match(/resolver:\s*([\w:]+)/)
|
|
539
|
+
field_hash[:resolver_class] = resolver_match[1] if resolver_match
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
fields << field_hash
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
fields
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Extract argument definitions
|
|
549
|
+
#
|
|
550
|
+
# @param source [String]
|
|
551
|
+
# @param runtime_class [Class, nil]
|
|
552
|
+
# @return [Array<Hash>]
|
|
553
|
+
def extract_arguments(source, runtime_class)
|
|
554
|
+
# Prefer runtime introspection
|
|
555
|
+
if runtime_class.respond_to?(:arguments) && runtime_class.arguments.any?
|
|
556
|
+
return extract_arguments_from_runtime(runtime_class)
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
extract_arguments_from_source(source)
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Extract arguments via runtime reflection
|
|
563
|
+
#
|
|
564
|
+
# @param runtime_class [Class]
|
|
565
|
+
# @return [Array<Hash>]
|
|
566
|
+
def extract_arguments_from_runtime(runtime_class)
|
|
567
|
+
runtime_class.arguments.map do |name, arg|
|
|
568
|
+
{
|
|
569
|
+
name: name,
|
|
570
|
+
type: arg.type.to_type_signature,
|
|
571
|
+
required: arg.type.non_null?,
|
|
572
|
+
description: arg.description
|
|
573
|
+
}
|
|
574
|
+
end
|
|
575
|
+
rescue StandardError
|
|
576
|
+
[]
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Extract arguments by parsing source text
|
|
580
|
+
#
|
|
581
|
+
# @param source [String]
|
|
582
|
+
# @return [Array<Hash>]
|
|
583
|
+
def extract_arguments_from_source(source)
|
|
584
|
+
args = []
|
|
585
|
+
|
|
586
|
+
source.scan(/argument\s+:(\w+)(?:,\s*(\S+?))?(?:,\s*(.+?))?$/) do |name, type, rest|
|
|
587
|
+
arg_hash = { name: name, type: type }
|
|
588
|
+
|
|
589
|
+
if rest
|
|
590
|
+
arg_hash[:required] = rest.include?('required: true')
|
|
591
|
+
desc_match = rest.match(/description:\s*["']([^"']+)["']/)
|
|
592
|
+
arg_hash[:description] = desc_match[1] if desc_match
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
args << arg_hash
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
args
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Extract interface implementations
|
|
602
|
+
#
|
|
603
|
+
# @param source [String]
|
|
604
|
+
# @param runtime_class [Class, nil]
|
|
605
|
+
# @return [Array<String>]
|
|
606
|
+
def extract_interfaces(source, runtime_class)
|
|
607
|
+
if runtime_class.respond_to?(:interfaces) && runtime_class.interfaces.any?
|
|
608
|
+
return runtime_class.interfaces.filter_map(&:name)
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
source.scan(/implements\s+([\w:]+)/).flatten
|
|
612
|
+
rescue StandardError
|
|
613
|
+
source.scan(/implements\s+([\w:]+)/).flatten
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
# Extract connection type references
|
|
617
|
+
#
|
|
618
|
+
# @param source [String]
|
|
619
|
+
# @return [Array<String>]
|
|
620
|
+
def extract_connections(source)
|
|
621
|
+
# field :items, Types::ItemType.connection_type
|
|
622
|
+
connections = source.scan(/([\w:]+)\.connection_type/).flatten.map do |type|
|
|
623
|
+
type
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# connection_type_class ConnectionType
|
|
627
|
+
source.scan(/connection_type_class\s+([\w:]+)/).flatten.each do |type|
|
|
628
|
+
connections << type
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
connections.uniq
|
|
632
|
+
end
|
|
633
|
+
|
|
634
|
+
# Extract references to standalone resolver classes
|
|
635
|
+
#
|
|
636
|
+
# @param source [String]
|
|
637
|
+
# @return [Array<String>]
|
|
638
|
+
def extract_resolver_references(source)
|
|
639
|
+
source.scan(/resolver:\s*([\w:]+)/).flatten.uniq
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# Detect authorization patterns
|
|
643
|
+
#
|
|
644
|
+
# @param source [String]
|
|
645
|
+
# @return [Hash]
|
|
646
|
+
def extract_authorization(source)
|
|
647
|
+
auth = {}
|
|
648
|
+
|
|
649
|
+
auth[:has_authorized_method] = source.match?(/def\s+(?:self\.)?authorized\?/) || false
|
|
650
|
+
auth[:pundit] = source.match?(/PolicyFinder|policy_class|authorize!?\s/) || false
|
|
651
|
+
auth[:cancan] = source.match?(/can\?|authorize!\s|CanCan|Ability/) || false
|
|
652
|
+
auth[:custom_guard] = source.match?(/def\s+(?:self\.)?(?:visible\?|scope_items|ready\?)/) || false
|
|
653
|
+
|
|
654
|
+
auth
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Extract field complexity settings
|
|
658
|
+
#
|
|
659
|
+
# @param source [String]
|
|
660
|
+
# @return [Array<Hash>]
|
|
661
|
+
def extract_complexity(source)
|
|
662
|
+
complexities = []
|
|
663
|
+
|
|
664
|
+
source.scan(/field\s+:(\w+).*?complexity:\s*(\d+|->.*?(?:end|\}))/m) do |name, value|
|
|
665
|
+
complexities << { field: name, complexity: value.strip }
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Max complexity on schema level
|
|
669
|
+
if source.match?(/max_complexity\s+(\d+)/)
|
|
670
|
+
complexities << { field: :schema, complexity: ::Regexp.last_match(1).to_i }
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
complexities
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Extract enum values (for enum types)
|
|
677
|
+
#
|
|
678
|
+
# @param source [String]
|
|
679
|
+
# @param runtime_class [Class, nil]
|
|
680
|
+
# @return [Array<Hash>]
|
|
681
|
+
def extract_enum_values(source, runtime_class)
|
|
682
|
+
if runtime_class.respond_to?(:values) && runtime_class.values.is_a?(Hash)
|
|
683
|
+
return runtime_class.values.map do |name, value_obj|
|
|
684
|
+
{
|
|
685
|
+
name: name,
|
|
686
|
+
value: value_obj.respond_to?(:value) ? value_obj.value : name,
|
|
687
|
+
description: value_obj.respond_to?(:description) ? value_obj.description : nil
|
|
688
|
+
}
|
|
689
|
+
end
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
# Parse from source
|
|
693
|
+
values = []
|
|
694
|
+
source.scan(/value\s+["'](\w+)["'](?:.*?description:\s*["']([^"']+)["'])?/) do |name, desc|
|
|
695
|
+
values << { name: name, description: desc }
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
values
|
|
699
|
+
rescue StandardError
|
|
700
|
+
[]
|
|
701
|
+
end
|
|
702
|
+
|
|
703
|
+
# Extract union member types
|
|
704
|
+
#
|
|
705
|
+
# @param source [String]
|
|
706
|
+
# @param runtime_class [Class, nil]
|
|
707
|
+
# @return [Array<String>]
|
|
708
|
+
def extract_union_members(source, runtime_class)
|
|
709
|
+
if runtime_class.respond_to?(:possible_types) && runtime_class.possible_types.any?
|
|
710
|
+
return runtime_class.possible_types.filter_map(&:name)
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
source.scan(/possible_types\s+(.+)$/).flatten.flat_map do |types_str|
|
|
714
|
+
types_str.scan(/([\w:]+)/).flatten
|
|
715
|
+
end
|
|
716
|
+
rescue StandardError
|
|
717
|
+
[]
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
721
|
+
# Field Counting Helpers
|
|
722
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
723
|
+
|
|
724
|
+
# Count fields from runtime or source
|
|
725
|
+
#
|
|
726
|
+
# @param source [String]
|
|
727
|
+
# @param runtime_class [Class, nil]
|
|
728
|
+
# @return [Integer]
|
|
729
|
+
def count_fields(source, runtime_class)
|
|
730
|
+
if runtime_class.respond_to?(:fields)
|
|
731
|
+
runtime_class.fields.size
|
|
732
|
+
else
|
|
733
|
+
source.scan(/^\s*field\s+:/).size
|
|
734
|
+
end
|
|
735
|
+
rescue StandardError
|
|
736
|
+
source.scan(/^\s*field\s+:/).size
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# Count arguments from runtime or source
|
|
740
|
+
#
|
|
741
|
+
# @param source [String]
|
|
742
|
+
# @param runtime_class [Class, nil]
|
|
743
|
+
# @return [Integer]
|
|
744
|
+
def count_arguments(source, runtime_class)
|
|
745
|
+
if runtime_class.respond_to?(:arguments)
|
|
746
|
+
runtime_class.arguments.size
|
|
747
|
+
else
|
|
748
|
+
source.scan(/^\s*argument\s+:/).size
|
|
749
|
+
end
|
|
750
|
+
rescue StandardError
|
|
751
|
+
source.scan(/^\s*argument\s+:/).size
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
755
|
+
# Dependency Extraction
|
|
756
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
757
|
+
|
|
758
|
+
# Extract all dependencies from source text
|
|
759
|
+
#
|
|
760
|
+
# Uses pattern scanning (not AR descendant iteration) to avoid O(n^2).
|
|
761
|
+
#
|
|
762
|
+
# @param source [String]
|
|
763
|
+
# @return [Array<Hash>]
|
|
764
|
+
def extract_dependencies(source, identifier = nil)
|
|
765
|
+
# Other GraphQL type references (Types::*), excluding self-references
|
|
766
|
+
deps = source.scan(/Types::\w+/).uniq.filter_map do |type_ref|
|
|
767
|
+
next if type_ref == identifier
|
|
768
|
+
|
|
769
|
+
{ type: :graphql_type, target: type_ref, via: :type_reference }
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Model references: scan for capitalized constants that look like model names.
|
|
773
|
+
# GraphQL uses its own pattern (not ModelNameCache) to avoid O(n^2).
|
|
774
|
+
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|
|
|
775
|
+
deps << { type: :model, target: model_ref, via: :code_reference }
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
source.scan(/\b([A-Z][a-z][a-zA-Z]*)\b/).flatten.uniq.each do |const_ref|
|
|
779
|
+
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/)
|
|
780
|
+
next
|
|
781
|
+
end
|
|
782
|
+
next if deps.any? { |d| d[:target] == const_ref }
|
|
783
|
+
|
|
784
|
+
if source.match?(/\b#{Regexp.escape(const_ref)}\.(?:find|where|find_by|create|new|first|last|all)\b/)
|
|
785
|
+
deps << { type: :model, target: const_ref, via: :code_reference }
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
deps.concat(scan_service_dependencies(source))
|
|
790
|
+
deps.concat(scan_job_dependencies(source))
|
|
791
|
+
deps.concat(scan_mailer_dependencies(source))
|
|
792
|
+
|
|
793
|
+
# Resolver dependencies (standalone resolver classes referenced in fields)
|
|
794
|
+
source.scan(/resolver:\s*([\w:]+)/).flatten.uniq.each do |resolver|
|
|
795
|
+
deps << { type: :graphql_resolver, target: resolver, via: :field_resolver }
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
deps.uniq { |d| [d[:type], d[:target]] }
|
|
799
|
+
end
|
|
800
|
+
|
|
801
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
802
|
+
# Chunking
|
|
803
|
+
# ──────────────────────────────────────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
# Build semantic chunks for large GraphQL types
|
|
806
|
+
#
|
|
807
|
+
# @param unit [ExtractedUnit]
|
|
808
|
+
# @param runtime_class [Class, nil]
|
|
809
|
+
# @return [Array<Hash>]
|
|
810
|
+
def build_chunks(unit, _runtime_class)
|
|
811
|
+
chunks = []
|
|
812
|
+
|
|
813
|
+
# Summary chunk: overview with field list
|
|
814
|
+
chunks << build_summary_chunk(unit)
|
|
815
|
+
|
|
816
|
+
# Field-group chunks for types with many fields
|
|
817
|
+
fields = unit.metadata[:fields] || []
|
|
818
|
+
if fields.size > 10
|
|
819
|
+
fields.each_slice(10).with_index do |field_group, idx|
|
|
820
|
+
chunks << build_field_group_chunk(unit, field_group, idx)
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
|
|
824
|
+
# Arguments chunk for mutations/resolvers
|
|
825
|
+
arguments = unit.metadata[:arguments] || []
|
|
826
|
+
chunks << build_arguments_chunk(unit, arguments) if arguments.any?
|
|
827
|
+
|
|
828
|
+
chunks
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
# Build a summary chunk with high-level type information
|
|
832
|
+
#
|
|
833
|
+
# @param unit [ExtractedUnit]
|
|
834
|
+
# @return [Hash]
|
|
835
|
+
def build_summary_chunk(unit)
|
|
836
|
+
meta = unit.metadata
|
|
837
|
+
fields = meta[:fields] || []
|
|
838
|
+
field_names = fields.map { |f| f[:name] }.compact
|
|
839
|
+
|
|
840
|
+
interfaces = meta[:interfaces] || []
|
|
841
|
+
auth = meta[:authorization] || {}
|
|
842
|
+
|
|
843
|
+
auth_summary = []
|
|
844
|
+
auth_summary << 'authorized?' if auth[:has_authorized_method]
|
|
845
|
+
auth_summary << 'pundit' if auth[:pundit]
|
|
846
|
+
auth_summary << 'cancan' if auth[:cancan]
|
|
847
|
+
|
|
848
|
+
{
|
|
849
|
+
chunk_type: :summary,
|
|
850
|
+
identifier: "#{unit.identifier}:summary",
|
|
851
|
+
content: <<~SUMMARY,
|
|
852
|
+
# #{unit.identifier} - #{format_type_label(unit.type)} Summary
|
|
853
|
+
|
|
854
|
+
Kind: #{meta[:graphql_kind]}
|
|
855
|
+
Parent: #{meta[:parent_class] || 'unknown'}
|
|
856
|
+
Fields: #{field_names.join(', ').presence || 'none'}
|
|
857
|
+
Interfaces: #{interfaces.join(', ').presence || 'none'}
|
|
858
|
+
Authorization: #{auth_summary.join(', ').presence || 'none'}
|
|
859
|
+
SUMMARY
|
|
860
|
+
metadata: { parent: unit.identifier, purpose: :overview }
|
|
861
|
+
}
|
|
862
|
+
end
|
|
863
|
+
|
|
864
|
+
# Build a chunk for a group of fields
|
|
865
|
+
#
|
|
866
|
+
# @param unit [ExtractedUnit]
|
|
867
|
+
# @param field_group [Array<Hash>]
|
|
868
|
+
# @param group_index [Integer]
|
|
869
|
+
# @return [Hash]
|
|
870
|
+
def build_field_group_chunk(unit, field_group, group_index)
|
|
871
|
+
lines = field_group.map do |f|
|
|
872
|
+
parts = ["field :#{f[:name]}"]
|
|
873
|
+
parts << f[:type] if f[:type]
|
|
874
|
+
parts << "(#{f[:description]})" if f[:description]
|
|
875
|
+
parts.join(', ')
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
{
|
|
879
|
+
chunk_type: :fields,
|
|
880
|
+
identifier: "#{unit.identifier}:fields_#{group_index}",
|
|
881
|
+
content: <<~FIELDS,
|
|
882
|
+
# #{unit.identifier} - Fields (group #{group_index})
|
|
883
|
+
|
|
884
|
+
#{lines.join("\n")}
|
|
885
|
+
FIELDS
|
|
886
|
+
metadata: { parent: unit.identifier, purpose: :fields, group_index: group_index }
|
|
887
|
+
}
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
# Build a chunk for arguments
|
|
891
|
+
#
|
|
892
|
+
# @param unit [ExtractedUnit]
|
|
893
|
+
# @param arguments [Array<Hash>]
|
|
894
|
+
# @return [Hash]
|
|
895
|
+
def build_arguments_chunk(unit, arguments)
|
|
896
|
+
lines = arguments.map do |a|
|
|
897
|
+
parts = ["argument :#{a[:name]}"]
|
|
898
|
+
parts << a[:type] if a[:type]
|
|
899
|
+
parts << 'required' if a[:required]
|
|
900
|
+
parts << "(#{a[:description]})" if a[:description]
|
|
901
|
+
parts.join(', ')
|
|
902
|
+
end
|
|
903
|
+
|
|
904
|
+
{
|
|
905
|
+
chunk_type: :arguments,
|
|
906
|
+
identifier: "#{unit.identifier}:arguments",
|
|
907
|
+
content: <<~ARGS,
|
|
908
|
+
# #{unit.identifier} - Arguments
|
|
909
|
+
|
|
910
|
+
#{lines.join("\n")}
|
|
911
|
+
ARGS
|
|
912
|
+
metadata: { parent: unit.identifier, purpose: :arguments }
|
|
913
|
+
}
|
|
914
|
+
end
|
|
915
|
+
end
|
|
916
|
+
end
|
|
917
|
+
end
|