rigortype 0.1.9 → 0.1.11

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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Graphql < Rigor::Plugin::Base
8
+ # Walks project source for `class T < GraphQL::Schema::Object`
9
+ # subclasses and emits a `{type_class_fqn => {field_name => {type:, nullable:}}}`
10
+ # table covering every `field :name, Type, null: ...` declaration
11
+ # inside the class body.
12
+ module TypeScanner
13
+ # The canonical GraphQL scalar type names accepted as
14
+ # `field`'s second positional argument. The plugin maps
15
+ # each to the underlying Ruby class name so downstream
16
+ # consumers can cross-reference against Ruby types
17
+ # without re-implementing the GraphQL→Ruby coercion
18
+ # table.
19
+ CANONICAL_TYPES = {
20
+ "String" => "String",
21
+ "Integer" => "Integer",
22
+ "Int" => "Integer",
23
+ "Boolean" => "TrueClass",
24
+ "Float" => "Float",
25
+ "ID" => "String"
26
+ }.freeze
27
+
28
+ # The base class name a Schema::Object subclass MUST
29
+ # inherit from to be recognised. Match is on the
30
+ # rightmost segment of the superclass constant chain so
31
+ # both `GraphQL::Schema::Object` and the locally-aliased
32
+ # `BaseObject = GraphQL::Schema::Object` shape work when
33
+ # the alias's RHS is the canonical path.
34
+ SCHEMA_OBJECT_TAIL = "Object"
35
+ SCHEMA_ENUM_TAIL = "Enum"
36
+ SCHEMA_INPUT_OBJECT_TAIL = "InputObject"
37
+ SCHEMA_MUTATION_TAIL = "Mutation"
38
+ # Common path-segment for `Schema::Object` / `Schema::Enum`
39
+ # / `Schema::InputObject` / `Schema::Mutation`; the
40
+ # second-to-last segment must be `Schema` (either
41
+ # fully-qualified `GraphQL::Schema::X` or lexically nested
42
+ # `Schema::X` inside `module GraphQL`).
43
+ SCHEMA_PARENT_SEGMENTS = %w[Schema GraphQL].freeze
44
+ private_constant :SCHEMA_OBJECT_TAIL, :SCHEMA_ENUM_TAIL,
45
+ :SCHEMA_INPUT_OBJECT_TAIL, :SCHEMA_MUTATION_TAIL,
46
+ :SCHEMA_PARENT_SEGMENTS
47
+
48
+ module_function
49
+
50
+ # @param paths [Array<String>] absolute paths to `.rb` files
51
+ # the project's `paths:` resolves to.
52
+ # @return [Hash{Symbol => Hash}] frozen 3-key result with
53
+ # `:types` (per-`Schema::Object` field table),
54
+ # `:enums` (per-`Schema::Enum` value list), and
55
+ # `:input_objects` (per-`Schema::InputObject` argument
56
+ # table). Any subset may be empty when no recognisable
57
+ # declaration of that kind is found.
58
+ def scan(paths:)
59
+ acc = empty_accumulator
60
+ paths.each do |path|
61
+ merge_accumulator(acc, scan_file(path))
62
+ end
63
+ freeze_accumulator(acc)
64
+ end
65
+
66
+ def empty_accumulator
67
+ { types: {}, enums: {}, input_objects: {}, mutations: {} }
68
+ end
69
+ private_class_method :empty_accumulator
70
+
71
+ def merge_accumulator(target, source)
72
+ source.each do |kind, table|
73
+ table.each { |k, v| target[kind][k] ||= v }
74
+ end
75
+ target
76
+ end
77
+ private_class_method :merge_accumulator
78
+
79
+ def freeze_accumulator(acc)
80
+ { types: acc[:types].freeze,
81
+ enums: acc[:enums].freeze,
82
+ input_objects: acc[:input_objects].freeze,
83
+ mutations: acc[:mutations].freeze }.freeze
84
+ end
85
+ private_class_method :freeze_accumulator
86
+
87
+ def scan_file(path)
88
+ source = File.read(path)
89
+ parse_result = Prism.parse(source, filepath: path)
90
+ return empty_accumulator unless parse_result.errors.empty?
91
+
92
+ collect_definitions(parse_result.value, [])
93
+ rescue StandardError
94
+ empty_accumulator
95
+ end
96
+ private_class_method :scan_file
97
+
98
+ # Walks the AST collecting `class X < GraphQL::Schema::Object`,
99
+ # `class X < GraphQL::Schema::Enum`, and
100
+ # `class X < GraphQL::Schema::InputObject` decls at any
101
+ # nesting level. Returns a 3-key hash so the caller can
102
+ # publish multiple cross-plugin facts from one walk.
103
+ def collect_definitions(node, qualified_prefix)
104
+ return empty_accumulator if node.nil?
105
+
106
+ case node
107
+ when Prism::ClassNode then collect_class_node(node, qualified_prefix)
108
+ when Prism::ModuleNode then collect_module_node(node, qualified_prefix)
109
+ else
110
+ node.compact_child_nodes.each_with_object(empty_accumulator) do |child, acc|
111
+ merge_accumulator(acc, collect_definitions(child, qualified_prefix))
112
+ end
113
+ end
114
+ end
115
+ private_class_method :collect_definitions
116
+
117
+ def collect_class_node(node, qualified_prefix)
118
+ inner_name = constant_name_for(node.constant_path)
119
+ return empty_accumulator if inner_name.nil?
120
+
121
+ new_prefix = qualified_prefix + [inner_name]
122
+ inner = collect_definitions(node.body, new_prefix)
123
+ register_subclass!(node, new_prefix, inner)
124
+ inner
125
+ end
126
+ private_class_method :collect_class_node
127
+
128
+ def register_subclass!(class_node, prefix, acc)
129
+ fqn = prefix.join("::")
130
+ if schema_subclass?(class_node, SCHEMA_OBJECT_TAIL)
131
+ fields = collect_fields(class_node.body)
132
+ acc[:types][fqn] ||= fields unless fields.empty?
133
+ elsif schema_subclass?(class_node, SCHEMA_ENUM_TAIL)
134
+ values = collect_values(class_node.body)
135
+ acc[:enums][fqn] ||= values unless values.empty?
136
+ elsif schema_subclass?(class_node, SCHEMA_INPUT_OBJECT_TAIL)
137
+ arguments = collect_arguments(class_node.body)
138
+ acc[:input_objects][fqn] ||= arguments unless arguments.empty?
139
+ elsif schema_subclass?(class_node, SCHEMA_MUTATION_TAIL)
140
+ arguments = collect_arguments(class_node.body)
141
+ fields = collect_fields(class_node.body)
142
+ shape = { arguments: arguments, fields: fields }
143
+ acc[:mutations][fqn] ||= shape unless arguments.empty? && fields.empty?
144
+ end
145
+ end
146
+ private_class_method :register_subclass!
147
+
148
+ def collect_module_node(node, qualified_prefix)
149
+ inner_name = constant_name_for(node.constant_path)
150
+ return empty_accumulator if inner_name.nil?
151
+
152
+ collect_definitions(node.body, qualified_prefix + [inner_name])
153
+ end
154
+ private_class_method :collect_module_node
155
+
156
+ # `class X < GraphQL::Schema::<Tail>` matches when the
157
+ # superclass's last two path segments are `Schema::<Tail>`.
158
+ # Matches both `< GraphQL::Schema::<Tail>` (fully qualified)
159
+ # and `< Schema::<Tail>` (lexically inside `module GraphQL`).
160
+ def schema_subclass?(class_node, tail)
161
+ superclass = class_node.superclass
162
+ return false if superclass.nil?
163
+
164
+ path = constant_path_segments(superclass)
165
+ return false if path.empty?
166
+ return false unless path.last == tail
167
+
168
+ SCHEMA_PARENT_SEGMENTS.include?(path[-2])
169
+ end
170
+ private_class_method :schema_subclass?
171
+
172
+ def collect_fields(body)
173
+ return {} if body.nil?
174
+
175
+ fields = {}
176
+ statement_nodes(body).each do |node|
177
+ next unless node.is_a?(Prism::CallNode) && node.name == :field
178
+
179
+ field = parse_field_call(node)
180
+ next if field.nil?
181
+
182
+ fields[field[:name]] = {
183
+ type: field[:type], nullable: field[:nullable], list: field[:list]
184
+ }
185
+ end
186
+ fields
187
+ end
188
+ private_class_method :collect_fields
189
+
190
+ def statement_nodes(body)
191
+ body.is_a?(Prism::StatementsNode) ? body.body : [body]
192
+ end
193
+ private_class_method :statement_nodes
194
+
195
+ # Walks every top-level `value "..."` call inside an
196
+ # enum subclass body and returns the value names as an
197
+ # Array<String>. Both shapes graphql-ruby accepts work:
198
+ #
199
+ # value "ACTIVE"
200
+ # value "DISABLED", value: :off, description: "..."
201
+ #
202
+ # The first positional must be a String literal — the
203
+ # graphql-ruby `value` API also accepts a Symbol form
204
+ # (`value :ACTIVE`) but the documented idiom is String.
205
+ # Slice 2b only stores the GraphQL-side value name; the
206
+ # optional `value:` kwarg (Ruby-side override) and
207
+ # `description:` stay out of the published table for
208
+ # the floor.
209
+ def collect_values(body)
210
+ return [] if body.nil?
211
+
212
+ values = []
213
+ statement_nodes(body).each do |node|
214
+ next unless node.is_a?(Prism::CallNode) && node.name == :value
215
+
216
+ arg = node.arguments&.arguments&.first
217
+ values << arg.unescaped if arg.is_a?(Prism::StringNode)
218
+ end
219
+ values
220
+ end
221
+ private_class_method :collect_values
222
+
223
+ # Walks every top-level `argument :name, Type, required: ...`
224
+ # call inside an InputObject (or Mutation) subclass body and
225
+ # returns the per-argument shape table. Argument syntax
226
+ # mirrors `field` except the nullability axis is named
227
+ # `required:` (default `false` — per graphql-ruby's
228
+ # `argument` default; the OPPOSITE polarity of `field`'s
229
+ # `null:`).
230
+ #
231
+ # argument :name, String, required: true
232
+ # argument :tags, [String], required: false
233
+ # argument :status, Types::Status, required: true
234
+ def collect_arguments(body)
235
+ return {} if body.nil?
236
+
237
+ arguments = {}
238
+ statement_nodes(body).each do |node|
239
+ next unless node.is_a?(Prism::CallNode) && node.name == :argument
240
+
241
+ argument = parse_argument_call(node)
242
+ next if argument.nil?
243
+
244
+ arguments[argument[:name]] = {
245
+ type: argument[:type], required: argument[:required], list: argument[:list]
246
+ }
247
+ end
248
+ arguments
249
+ end
250
+ private_class_method :collect_arguments
251
+
252
+ def parse_argument_call(node)
253
+ args = node.arguments&.arguments
254
+ return nil if args.nil? || args.size < 2
255
+
256
+ name_node = args[0]
257
+ type_node = args[1]
258
+ return nil unless name_node.is_a?(Prism::SymbolNode)
259
+
260
+ type_info = resolve_field_type(type_node)
261
+ return nil if type_info.nil?
262
+
263
+ {
264
+ name: name_node.unescaped,
265
+ type: type_info[:type],
266
+ list: type_info[:list],
267
+ required: extract_required_flag(args)
268
+ }
269
+ end
270
+ private_class_method :parse_argument_call
271
+
272
+ # Mirror of `extract_nullability` but reads the `required:`
273
+ # kwarg, defaulting to `false` (graphql-ruby's argument
274
+ # default — the OPPOSITE polarity of `field`'s `null:` /
275
+ # nullability default).
276
+ # rubocop:disable Naming/PredicateMethod -- extractor returns the literal required value
277
+ def extract_required_flag(args)
278
+ kwargs = args.last
279
+ return false unless kwargs.is_a?(Prism::KeywordHashNode)
280
+
281
+ pair = kwargs.elements.find do |el|
282
+ el.is_a?(Prism::AssocNode) && el.key.is_a?(Prism::SymbolNode) && el.key.unescaped == "required"
283
+ end
284
+ return false if pair.nil?
285
+
286
+ case pair.value
287
+ when Prism::TrueNode then true
288
+ when Prism::FalseNode then false
289
+ else false
290
+ end
291
+ end
292
+ # rubocop:enable Naming/PredicateMethod
293
+ private_class_method :extract_required_flag
294
+
295
+ # `field :name, Type, null: false` shape. The first
296
+ # positional is a Symbol (field name); the second is a
297
+ # constant reference (GraphQL type) OR a single-element
298
+ # ArrayNode (`[Type]`) for GraphQL list types; `null:` is
299
+ # the nullability keyword (defaults to TRUE per
300
+ # graphql-ruby's field defaults so we mirror that).
301
+ def parse_field_call(node)
302
+ args = node.arguments&.arguments
303
+ return nil if args.nil? || args.size < 2
304
+
305
+ name_node = args[0]
306
+ type_node = args[1]
307
+ return nil unless name_node.is_a?(Prism::SymbolNode)
308
+
309
+ type_info = resolve_field_type(type_node)
310
+ return nil if type_info.nil?
311
+
312
+ {
313
+ name: name_node.unescaped,
314
+ type: type_info[:type],
315
+ list: type_info[:list],
316
+ nullable: extract_nullability(args)
317
+ }
318
+ end
319
+ private_class_method :parse_field_call
320
+
321
+ # Resolves the `Type` positional argument to a
322
+ # `{type: "ClassName", list: bool}` tuple. ArrayNode
323
+ # forms (`[String]` / `[Types::User]`) unwrap the single
324
+ # element and mark `list: true`. Bare constant refs are
325
+ # not lists. Returns nil for unrecognised shapes (string
326
+ # types `"User"`, Proc lazy types, etc.) so callers drop
327
+ # the field.
328
+ def resolve_field_type(node)
329
+ if node.is_a?(Prism::ArrayNode)
330
+ element = node.elements.first
331
+ return nil if node.elements.size != 1 || element.nil?
332
+
333
+ inner = resolve_constant_type(element)
334
+ return nil if inner.nil?
335
+
336
+ { type: inner, list: true }
337
+ else
338
+ name = resolve_constant_type(node)
339
+ return nil if name.nil?
340
+
341
+ { type: name, list: false }
342
+ end
343
+ end
344
+ private_class_method :resolve_field_type
345
+
346
+ def resolve_constant_type(node)
347
+ name = constant_name_for(node)
348
+ return nil if name.nil?
349
+
350
+ tail = name.split("::").last
351
+ CANONICAL_TYPES[tail] || name
352
+ end
353
+ private_class_method :resolve_constant_type
354
+
355
+ # Defaults to `true` (matches graphql-ruby's `field`
356
+ # default nullability). Looks for an explicit `null:`
357
+ # keyword and reads its boolean literal.
358
+ # rubocop:disable Naming/PredicateMethod -- extractor returns the literal nullability value
359
+ def extract_nullability(args)
360
+ kwargs = args.last
361
+ return true unless kwargs.is_a?(Prism::KeywordHashNode)
362
+
363
+ null_pair = kwargs.elements.find do |el|
364
+ el.is_a?(Prism::AssocNode) && el.key.is_a?(Prism::SymbolNode) && el.key.unescaped == "null"
365
+ end
366
+ return true if null_pair.nil?
367
+
368
+ case null_pair.value
369
+ when Prism::TrueNode then true
370
+ when Prism::FalseNode then false
371
+ else true
372
+ end
373
+ end
374
+ # rubocop:enable Naming/PredicateMethod
375
+ private_class_method :extract_nullability
376
+
377
+ # Returns the constant chain as an Array of String
378
+ # segments (`["GraphQL", "Schema", "Object"]`). Empty
379
+ # array for unrecognised node kinds.
380
+ def constant_path_segments(node)
381
+ case node
382
+ when Prism::ConstantReadNode then [node.name.to_s]
383
+ when Prism::ConstantPathNode
384
+ segments = []
385
+ current = node
386
+ while current.is_a?(Prism::ConstantPathNode)
387
+ segments.unshift(current.name.to_s)
388
+ current = current.parent
389
+ end
390
+ segments.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
391
+ segments
392
+ else
393
+ []
394
+ end
395
+ end
396
+ private_class_method :constant_path_segments
397
+
398
+ # Joined `::`-form of {.constant_path_segments}. Returns
399
+ # nil for unrecognised node kinds (so callers can short-
400
+ # circuit).
401
+ def constant_name_for(node)
402
+ segments = constant_path_segments(node)
403
+ segments.empty? ? nil : segments.join("::")
404
+ end
405
+ private_class_method :constant_name_for
406
+ end
407
+ end
408
+ end
409
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/plugin"
6
+
7
+ require_relative "graphql/type_scanner"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-graphql — Tier 3 of the
12
+ # [Rails plugins roadmap](../../../../../docs/design/20260508-rails-plugins-roadmap.md)
13
+ # § "3D".
14
+ #
15
+ # Recognises `class T < GraphQL::Schema::Object` subclasses
16
+ # and walks every `field :name, Type, null: false` declaration
17
+ # inside, publishing the resulting field-type map as the
18
+ # `:graphql_type_table` cross-plugin fact (ADR-9). The macro
19
+ # expansion library survey at
20
+ # [docs/notes/20260515-macro-expansion-library-survey.md](../../../../../docs/notes/20260515-macro-expansion-library-survey.md)
21
+ # § "GraphQL-Ruby" documents WHY this is a pure metadata-recorder
22
+ # plugin rather than an ADR-16 substrate consumer: graphql-ruby's
23
+ # `field` DSL emits NO Ruby methods (it just records a
24
+ # `Schema::Field` on the class's `own_fields`). The user writes
25
+ # resolver methods themselves; rigor's value here is producing a
26
+ # static type table downstream consumers can cross-reference.
27
+ #
28
+ # ## What downstream consumers DO with `:graphql_type_table`
29
+ #
30
+ # The fact is the substrate for two future capabilities (both
31
+ # demand-driven, NOT in slice 1):
32
+ #
33
+ # - Resolver-method check: for each `field :name, Type` whose
34
+ # `name` is also defined as a Ruby method on the class, verify
35
+ # the method's return type matches `Type`'s underlying class.
36
+ # - Schema-query result typing: a future `rigor-graphql-execute`
37
+ # plugin could type `Schema.execute(query).to_h` against the
38
+ # queried fields.
39
+ #
40
+ # ## Floor / ceiling (slice 1)
41
+ #
42
+ # Slice 1 ships the **floor**:
43
+ #
44
+ # - Recognises `class T < GraphQL::Schema::Object` subclasses
45
+ # (including nested namespaces: `class Types::User < ...`,
46
+ # `module Types; class User < ...; end; end`).
47
+ # - Recognises the `field :name, Type, **opts` declaration with:
48
+ # - `Type` as a `ConstantReadNode` / `ConstantPathNode` (`String`
49
+ # / `Integer` / `Boolean` / `Float` / `ID`, or a user-defined
50
+ # `Types::OtherObject`).
51
+ # - `null: true` / `null: false` keyword extracts nullability.
52
+ # - Maps the canonical GraphQL scalar names to underlying Ruby
53
+ # classes (`String` → `String`, `Integer` → `Integer`,
54
+ # `Boolean` → `TrueClass`, `Float` → `Float`, `ID` → `String`).
55
+ # - Publishes the table; no user-facing diagnostics yet.
56
+ #
57
+ # The **ceiling** (future slices, demand-driven):
58
+ #
59
+ # - **`GraphQL::Schema::Enum`** with `value "ACTIVE"` calls.
60
+ # - **`GraphQL::Schema::Mutation`** + **`GraphQL::Schema::InputObject`**.
61
+ # - **List / Non-Null wrappers** (`[String]`, `String.array`).
62
+ # - **`resolver:` / `mutation:` reroute** recognition.
63
+ # - **String type expressions** (`field :foo, "User"`) — defeats
64
+ # static resolution by design (graphql-ruby's `BuildType.parse_type`
65
+ # constantizes at runtime); a future slice could surface these
66
+ # as `graphql.string-type` `:info` diagnostics that point the
67
+ # user at the constant-reference form for static typing.
68
+ class Graphql < Rigor::Plugin::Base
69
+ manifest(
70
+ id: "graphql",
71
+ version: "0.1.0",
72
+ description: "Recognises `class T < GraphQL::Schema::{Object,Enum,InputObject,Mutation}` " \
73
+ "subclasses; publishes the per-type field-type table, the per-enum value " \
74
+ "list, the per-input-object argument table, and the per-mutation arguments+fields " \
75
+ "table.",
76
+ produces: %i[graphql_type_table graphql_enum_table graphql_input_object_table graphql_mutation_table]
77
+ )
78
+
79
+ def prepare(services)
80
+ scanned = TypeScanner.scan(paths: scannable_paths(services))
81
+ publish_if_present(services, :graphql_type_table, scanned.fetch(:types))
82
+ publish_if_present(services, :graphql_enum_table, scanned.fetch(:enums))
83
+ publish_if_present(services, :graphql_input_object_table, scanned.fetch(:input_objects))
84
+ publish_if_present(services, :graphql_mutation_table, scanned.fetch(:mutations))
85
+ end
86
+
87
+ def init(_services)
88
+ @scannable_paths = nil
89
+ end
90
+
91
+ private
92
+
93
+ def publish_if_present(services, name, value)
94
+ return if value.nil? || value.empty?
95
+
96
+ services.fact_store.publish(plugin_id: manifest.id, name: name, value: value)
97
+ end
98
+
99
+ def scannable_paths(services)
100
+ @scannable_paths ||= services.configuration.paths.flat_map do |entry|
101
+ if File.directory?(entry)
102
+ Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
103
+ elsif File.file?(entry) && entry.end_with?(".rb")
104
+ [entry]
105
+ else
106
+ []
107
+ end
108
+ end.uniq.freeze
109
+ end
110
+ end
111
+
112
+ Rigor::Plugin.register(Graphql)
113
+ end
114
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem entry point. Required by Rigor's plugin loader when
4
+ # `.rigor.yml` lists `rigor-graphql` under `plugins:`. The
5
+ # loader expects this `require` to side-effect a call to
6
+ # `Rigor::Plugin.register`, which the body of
7
+ # `lib/rigor/plugin/graphql.rb` performs at load time.
8
+ require_relative "rigor/plugin/graphql"
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class Hanami < Rigor::Plugin::Base
8
+ # Checks the "presence" half of the Hanami::Action
9
+ # ADR-28 protocol contract.
10
+ #
11
+ # Every class defined in a file matching a contract's
12
+ # `path_glob` must declare `#handle(request, response)`.
13
+ # The "provide" half — binding `Hanami::Action::Request`
14
+ # and `Hanami::Action::Response` into the method body —
15
+ # is handled engine-side by
16
+ # `Inference::MethodParameterBinder`. Return type is
17
+ # void so no conformance check is performed here.
18
+ class ActionChecker
19
+ FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
20
+
21
+ def initialize(contracts:)
22
+ @contracts = contracts
23
+ end
24
+
25
+ def check(path:, root:)
26
+ @contracts.flat_map do |contract|
27
+ next [] unless path_matches?(contract.path_glob, path)
28
+
29
+ class_nodes(root).filter_map do |class_node|
30
+ handle_def = find_handle(class_node, contract)
31
+ if handle_def.nil?
32
+ missing_handle_diagnostic(contract, path, class_node)
33
+ else
34
+ handle_arity_mismatch_diagnostic(contract, path, class_node, handle_def)
35
+ end
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ # Contract globs are project-root-relative; the analyzer
43
+ # may supply a relative or absolute path, so the glob is
44
+ # matched both directly and with a `**/`-prefixed suffix.
45
+ def path_matches?(glob, path)
46
+ return false if path.nil?
47
+
48
+ File.fnmatch?(glob, path, FNMATCH_FLAGS) ||
49
+ File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
50
+ end
51
+
52
+ def class_nodes(root)
53
+ found = []
54
+ walk(root) { |node| found << node if node.is_a?(Prism::ClassNode) }
55
+ found
56
+ end
57
+
58
+ def find_handle(class_node, contract)
59
+ direct_defs(class_node).find do |def_node|
60
+ def_node.name == contract.method_name &&
61
+ !def_node.receiver.is_a?(Prism::SelfNode)
62
+ end
63
+ end
64
+
65
+ def direct_defs(class_node)
66
+ defs = []
67
+ collect_direct_defs(class_node.body, defs)
68
+ defs
69
+ end
70
+
71
+ def collect_direct_defs(node, defs)
72
+ return if node.nil?
73
+
74
+ case node
75
+ when Prism::DefNode then defs << node
76
+ when Prism::ClassNode, Prism::ModuleNode then nil # nested scopes own their own defs
77
+ else node.compact_child_nodes.each { |child| collect_direct_defs(child, defs) }
78
+ end
79
+ end
80
+
81
+ def handle_arity_mismatch_diagnostic(contract, path, class_node, def_node)
82
+ req_count = def_node.parameters ? def_node.parameters.requireds.size : 0
83
+ return nil if req_count == 2
84
+
85
+ location = def_node.location
86
+ Rigor::Analysis::Diagnostic.new(
87
+ path: path,
88
+ line: location.start_line,
89
+ column: location.start_column + 1,
90
+ message: "`#{class_name(class_node)}#handle` must accept exactly 2 parameters " \
91
+ "(request, response), got #{req_count}",
92
+ severity: contract.severity,
93
+ rule: "handle-arity-mismatch"
94
+ )
95
+ end
96
+
97
+ def missing_handle_diagnostic(contract, path, class_node)
98
+ location = (class_node.constant_path || class_node).location
99
+ Rigor::Analysis::Diagnostic.new(
100
+ path: path,
101
+ line: location.start_line,
102
+ column: location.start_column + 1,
103
+ message: "`#{class_name(class_node)}` must define `#handle(request, response)` — " \
104
+ "required of every Hanami action under `#{contract.path_glob}`",
105
+ severity: contract.severity,
106
+ rule: "missing-handle-method"
107
+ )
108
+ end
109
+
110
+ def class_name(class_node)
111
+ path = class_node.constant_path
112
+ path.respond_to?(:slice) ? path.slice : class_node.name.to_s
113
+ end
114
+
115
+ def walk(node, &)
116
+ return if node.nil?
117
+
118
+ yield node
119
+ node.compact_child_nodes.each { |child| walk(child, &) }
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end