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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- 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
|