graphql-doctor 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.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +71 -0
  4. data/Rakefile +9 -0
  5. data/docs/diagnostics/GQLD101.md +7 -0
  6. data/docs/diagnostics/GQLD102.md +7 -0
  7. data/docs/diagnostics/GQLD103.md +7 -0
  8. data/docs/diagnostics/GQLD104.md +7 -0
  9. data/docs/diagnostics/GQLD201.md +7 -0
  10. data/docs/diagnostics/GQLD202.md +7 -0
  11. data/docs/diagnostics/GQLD203.md +7 -0
  12. data/docs/diagnostics/GQLD204.md +7 -0
  13. data/docs/diagnostics/GQLD205.md +7 -0
  14. data/docs/diagnostics/GQLD301.md +7 -0
  15. data/docs/diagnostics/GQLD302.md +7 -0
  16. data/docs/diagnostics/GQLD303.md +7 -0
  17. data/docs/diagnostics/GQLD305.md +7 -0
  18. data/docs/diagnostics/GQLD306.md +7 -0
  19. data/docs/diagnostics/GQLD307.md +7 -0
  20. data/docs/diagnostics/GQLD401.md +7 -0
  21. data/docs/diagnostics/README.md +29 -0
  22. data/exe/graphql-doctor +6 -0
  23. data/lib/graphql/doctor/cache.rb +47 -0
  24. data/lib/graphql/doctor/checks/argument_keyword_match.rb +308 -0
  25. data/lib/graphql/doctor/checks/base.rb +121 -0
  26. data/lib/graphql/doctor/checks/engine.rb +74 -0
  27. data/lib/graphql/doctor/checks/resolver_method_presence.rb +68 -0
  28. data/lib/graphql/doctor/cli.rb +227 -0
  29. data/lib/graphql/doctor/config.rb +168 -0
  30. data/lib/graphql/doctor/correlation/correlator.rb +58 -0
  31. data/lib/graphql/doctor/diagnostic.rb +51 -0
  32. data/lib/graphql/doctor/ir.rb +30 -0
  33. data/lib/graphql/doctor/location.rb +52 -0
  34. data/lib/graphql/doctor/reporters/github.rb +34 -0
  35. data/lib/graphql/doctor/reporters/json.rb +19 -0
  36. data/lib/graphql/doctor/reporters/sarif.rb +82 -0
  37. data/lib/graphql/doctor/reporters/text.rb +47 -0
  38. data/lib/graphql/doctor/runner.rb +71 -0
  39. data/lib/graphql/doctor/runtime/dump.rb +137 -0
  40. data/lib/graphql/doctor/runtime/reflector.rb +313 -0
  41. data/lib/graphql/doctor/runtime/schema_loader.rb +40 -0
  42. data/lib/graphql/doctor/source/file_visitor.rb +366 -0
  43. data/lib/graphql/doctor/source/index.rb +164 -0
  44. data/lib/graphql/doctor/source/loader.rb +146 -0
  45. data/lib/graphql/doctor/source/name_mangler.rb +31 -0
  46. data/lib/graphql/doctor/suppression.rb +39 -0
  47. data/lib/graphql/doctor/version.rb +7 -0
  48. data/lib/graphql/doctor.rb +23 -0
  49. metadata +105 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 227a766e280cad57f2425bdb6806a7f0ef894142dca02f9bf7e2916f30ae47b2
4
+ data.tar.gz: 42590b9a638b5bcd94b701869fe86453bb82b39718e54bbc45ac6d3c81afac31
5
+ SHA512:
6
+ metadata.gz: '0921f5b6fd1ad4187ac438b6c0425cafebcf9488a0a9524f47fb2cf76ae706ccbde78bcaae58325abc70c1cea22b37dd5d3a1c661d5529bdf7d42c27065eaa1d'
7
+ data.tar.gz: 370503328e8b9b424739d9f78e17ff11c29d679e737c6950b9c43c7d19885001478d96bc6eca53cf9fee480e58935c2b34398010942cb9ecd573a175d4ee2acc
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # graphql-doctor
2
+
3
+ `graphql-doctor` maps GraphQL-Ruby's runtime schema back to Prism-parsed Ruby source and checks resolver contracts before a query reaches production.
4
+
5
+ ## Installation
6
+
7
+ Add it to the application that owns the schema:
8
+
9
+ ```ruby
10
+ group :development, :test do
11
+ gem "graphql-doctor"
12
+ end
13
+ ```
14
+
15
+ The gem supports Ruby 3.1+ and GraphQL-Ruby 2.0+. GraphQL-Ruby 2.0.0 itself cannot build interface schemas on Ruby 3.4, so use 2.0.32 or newer for that combination. The gem's only runtime dependency is Prism.
16
+
17
+ ## Configuration
18
+
19
+ Create `.graphql-doctor.yml` in the application root:
20
+
21
+ ```yaml
22
+ schema: MyAppSchema
23
+ require: ./config/environment
24
+ include:
25
+ - app/graphql/**/*.rb
26
+ exclude:
27
+ - app/graphql/legacy/**/*.rb
28
+
29
+ checks:
30
+ GQLD201: { severity: warning }
31
+ GQLD305: { enabled: true }
32
+
33
+ allow_underlying_object:
34
+ - Types::UserType
35
+ abstract_classes:
36
+ - Mutations::BaseMutation
37
+ ```
38
+
39
+ ## Commands
40
+
41
+ ```sh
42
+ bundle exec graphql-doctor check
43
+ bundle exec graphql-doctor check app/graphql --format github
44
+ bundle exec graphql-doctor dump-schema --out tmp/schema.json
45
+ bundle exec graphql-doctor check --no-boot --schema-dump tmp/schema.json
46
+ bundle exec graphql-doctor coverage
47
+ bundle exec graphql-doctor explain GQLD301
48
+ ```
49
+
50
+ `check` exits with `0` when the configured threshold is clear, `1` for diagnostics at or above `--fail-level`, and `2` for configuration or boot failures. Formats are `text`, `json`, `sarif`, and `github`. `NO_COLOR` and `--no-color` disable ANSI output.
51
+
52
+ For CI split boot from checking by passing the output of `dump-schema` to `--schema-dump`. Cache files are stored under `.graphql-doctor/cache`; add `.graphql-doctor/` to the target application's `.gitignore`.
53
+
54
+ ## Suppression
55
+
56
+ Suppress one line or a whole file:
57
+
58
+ ```ruby
59
+ def resolve(id:) # graphql-doctor:disable GQLD301 -- handled by an extension
60
+ # graphql-doctor:disable-file GQLD201 -- objects provide all fields
61
+ ```
62
+
63
+ Set `require_suppression_reason: true` to ignore suppression comments without trailing reasons.
64
+
65
+ ## Development
66
+
67
+ Run `bundle exec rake test`. See [diagnostic references](docs/diagnostics) for causes and fixes.
68
+
69
+ ## License
70
+
71
+ MIT. See [LICENSE.txt](LICENSE.txt).
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task test: :spec
9
+ task default: :spec
@@ -0,0 +1,7 @@
1
+ # GQLD101: Schema boot failed
2
+
3
+ - Cause: the configured environment could not load or the schema constant could not be resolved.
4
+ - Runtime impact: runtime-backed checks cannot run.
5
+ - Fix: correct `require:` and `schema:` in `.graphql-doctor.yml`.
6
+ - False positives: none; the underlying exception is included.
7
+ - Suppression: not supported because this is an execution failure.
@@ -0,0 +1,7 @@
1
+ # GQLD102: Source-only field
2
+
3
+ - Cause: a statically declared field is absent from the selected runtime schema.
4
+ - Runtime impact: the field is not queryable in that schema.
5
+ - Fix: register its owner or remove the declaration.
6
+ - False positives: visibility rules and multiple schemas can cause this; the check is off by default.
7
+ - Suppression: use `graphql-doctor:disable GQLD102` or disable the check in configuration.
@@ -0,0 +1,7 @@
1
+ # GQLD103: Dynamic definition
2
+
3
+ - Cause: a field or argument is declared under a loop, condition, include, or unsupported macro.
4
+ - Runtime impact: no contract error is asserted; source mapping is incomplete.
5
+ - Fix: use a literal GraphQL DSL declaration when source mapping is required.
6
+ - False positives: intentional metaprogramming is expected; the check is off by default.
7
+ - Suppression: use `graphql-doctor:disable GQLD103`.
@@ -0,0 +1,7 @@
1
+ # GQLD104: Ruby parse failed
2
+
3
+ - Cause: Prism could not parse a selected Ruby file.
4
+ - Runtime impact: declarations in that file are skipped.
5
+ - Fix: correct the reported Ruby syntax or exclude unsupported generated input.
6
+ - False positives: version-specific syntax requires a compatible Prism version.
7
+ - Suppression: fix or exclude the file; hiding parse failures is discouraged.
@@ -0,0 +1,7 @@
1
+ # GQLD201: Resolver method not found
2
+
3
+ - Cause: no type or resolver method was found for a field without `hash_key:` or `dig:`.
4
+ - Runtime impact: GraphQL-Ruby may fall through to the underlying object or raise at execution.
5
+ - Fix: define the method or add the owner to `allow_underlying_object`.
6
+ - False positives: underlying domain objects are intentionally not guessed; severity is warning.
7
+ - Suppression: use `graphql-doctor:disable GQLD201`.
@@ -0,0 +1,7 @@
1
+ # GQLD202: Explicit resolver method not found
2
+
3
+ - Cause: `resolver_method:` or a resolver class points to a missing method.
4
+ - Runtime impact: executing the field raises a method error.
5
+ - Fix: define or rename the resolver method.
6
+ - False positives: dynamically installed methods are not visible to static source indexing.
7
+ - Suppression: use `graphql-doctor:disable GQLD202` for deliberate metaprogramming.
@@ -0,0 +1,7 @@
1
+ # GQLD203: Explicit field method not found
2
+
3
+ - Cause: `method:` names a method missing from a statically known underlying object class.
4
+ - Runtime impact: field execution can raise a method error.
5
+ - Fix: correct `method:` or define the target method.
6
+ - False positives: opaque underlying objects are reported as GQLD201 instead, without asserting that the method is absent.
7
+ - Suppression: use `graphql-doctor:disable GQLD203` when the target is dynamic.
@@ -0,0 +1,7 @@
1
+ # GQLD204: Resolver method is not public
2
+
3
+ - Cause: a resolver target is declared private or protected.
4
+ - Runtime impact: GraphQL-Ruby uses public dispatch and cannot call it.
5
+ - Fix: make the method public or point the field at a public method.
6
+ - False positives: none for statically declared visibility.
7
+ - Suppression: use `graphql-doctor:disable GQLD204` only with a custom dispatch layer.
@@ -0,0 +1,7 @@
1
+ # GQLD205: Invalid prepare callback
2
+
3
+ - Cause: a symbolic `prepare:` method is missing or has the wrong visibility or arity.
4
+ - Runtime impact: argument preparation fails before resolver execution.
5
+ - Fix: define the named callback without required keywords. GraphQL-Ruby first uses a public Object instance method taking `value`; since 2.1.8, Resolver and Mutation arguments may fall back to a class method taking `value, context`.
6
+ - False positives: dynamically defined callbacks cannot be confirmed.
7
+ - Suppression: use `graphql-doctor:disable GQLD205`.
@@ -0,0 +1,7 @@
1
+ # GQLD301: GraphQL keyword is not accepted
2
+
3
+ - Cause: a runtime argument keyword or extra is absent from the Ruby signature.
4
+ - Runtime impact: resolver dispatch raises an unknown keyword error.
5
+ - Fix: accept the reported keyword or `**arguments`; respect `loads:` and `as:` conversions.
6
+ - False positives: dynamic forwarding is skipped automatically.
7
+ - Suppression: use `graphql-doctor:disable GQLD301`.
@@ -0,0 +1,7 @@
1
+ # GQLD302: Ruby requires an input GraphQL cannot provide
2
+
3
+ - Cause: a required Ruby keyword has no runtime GraphQL argument or extra, or the resolver requires a positional argument.
4
+ - Runtime impact: resolver dispatch raises a missing keyword or wrong-number-of-arguments error.
5
+ - Fix: rename/remove the keyword, declare the matching GraphQL argument, or change positional parameters to keywords.
6
+ - False positives: extensions may supply unknown values; model them as extras or accept `**arguments`.
7
+ - Suppression: use `graphql-doctor:disable GQLD302`.
@@ -0,0 +1,7 @@
1
+ # GQLD303: Optional GraphQL argument is required by Ruby
2
+
3
+ - Cause: GraphQL may omit an argument but the Ruby method requires its keyword.
4
+ - Runtime impact: queries that omit it raise a missing keyword error.
5
+ - Fix: use `keyword: nil`, add `default_value:`, use non-null/`:nullable`, or accept `**arguments`.
6
+ - False positives: none when runtime argument metadata is available.
7
+ - Suppression: use `graphql-doctor:disable GQLD303`.
@@ -0,0 +1,7 @@
1
+ # GQLD305: Unknown optional Ruby keyword
2
+
3
+ - Cause: an optional Ruby keyword has no known GraphQL argument or extra.
4
+ - Runtime impact: usually none; an extension may supply it.
5
+ - Fix: remove stale parameters or declare the source of the keyword.
6
+ - False positives: extension-provided keywords are common; the check is off by default.
7
+ - Suppression: use `graphql-doctor:disable GQLD305`.
@@ -0,0 +1,7 @@
1
+ # GQLD306: GraphQL extra is not accepted
2
+
3
+ - Cause: `extras:` adds a keyword absent from the resolver signature.
4
+ - Runtime impact: resolver dispatch raises an unknown keyword error.
5
+ - Fix: accept the named extra or `**arguments`.
6
+ - False positives: none when runtime extras are reflected.
7
+ - Suppression: use `graphql-doctor:disable GQLD306`.
@@ -0,0 +1,7 @@
1
+ # GQLD307: Callback signature mismatch
2
+
3
+ - Cause: `ready?`, `authorized?`, or an enabled experimental execution callback has an incompatible signature.
4
+ - Runtime impact: callback dispatch fails before or during resolution.
5
+ - Fix: accept the runtime argument set; experimental callbacks must accept their fixed keywords and `**arguments`.
6
+ - False positives: forwarding (`...`) callbacks are skipped.
7
+ - Suppression: use `graphql-doctor:disable GQLD307`.
@@ -0,0 +1,7 @@
1
+ # GQLD401: Resolver or mutation is unregistered
2
+
3
+ - Cause: a concrete resolver or mutation class is not referenced by any runtime field.
4
+ - Runtime impact: the member is unreachable through the schema.
5
+ - Fix: register it or remove dead code; list abstract bases under `abstract_classes`.
6
+ - False positives: a different schema may register the class.
7
+ - Suppression: use `graphql-doctor:disable GQLD401`.
@@ -0,0 +1,29 @@
1
+ # Diagnostic reference
2
+
3
+ Each diagnostic page explains the cause, runtime impact, fix, possible false positives, and suppression options.
4
+ Read one from the command line with:
5
+
6
+ ```sh
7
+ bundle exec graphql-doctor explain GQLD301
8
+ ```
9
+
10
+ | Code | Diagnostic | Default |
11
+ | --- | --- | --- |
12
+ | [GQLD101](GQLD101.md) | Schema boot failed | error |
13
+ | [GQLD102](GQLD102.md) | Source-only field | info, disabled |
14
+ | [GQLD103](GQLD103.md) | Dynamic definition | info, disabled |
15
+ | [GQLD104](GQLD104.md) | Ruby parse failed | error |
16
+ | [GQLD201](GQLD201.md) | Resolver method not found | warning |
17
+ | [GQLD202](GQLD202.md) | Explicit resolver method not found | error |
18
+ | [GQLD203](GQLD203.md) | Explicit field method not found | error |
19
+ | [GQLD204](GQLD204.md) | Resolver method is not public | warning |
20
+ | [GQLD205](GQLD205.md) | Invalid prepare callback | error |
21
+ | [GQLD301](GQLD301.md) | GraphQL keyword is not accepted | error |
22
+ | [GQLD302](GQLD302.md) | Ruby requires an input GraphQL cannot provide | error |
23
+ | [GQLD303](GQLD303.md) | Optional GraphQL argument is required by Ruby | error |
24
+ | [GQLD305](GQLD305.md) | Unknown optional Ruby keyword | info, disabled |
25
+ | [GQLD306](GQLD306.md) | GraphQL extra is not accepted | error |
26
+ | [GQLD307](GQLD307.md) | Callback signature mismatch | error |
27
+ | [GQLD401](GQLD401.md) | Resolver or mutation is unregistered | warning |
28
+
29
+ Configure severities and opt-in checks under `checks` in `.graphql-doctor.yml`. See the project [README](../../README.md#configuration) for configuration and suppression examples.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "graphql/doctor/cli"
5
+
6
+ exit GraphQL::Doctor::CLI.start
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require_relative "source/index"
7
+
8
+ module GraphQL
9
+ module Doctor
10
+ class Cache
11
+ FORMAT_VERSION = 3
12
+
13
+ def initialize(directory: ".graphql-doctor/cache")
14
+ @directory = directory
15
+ end
16
+
17
+ def fetch(path, config_digest: "")
18
+ begin
19
+ source = File.binread(path)
20
+ rescue SystemCallError
21
+ return yield
22
+ end
23
+
24
+ key = Digest::SHA256.hexdigest(
25
+ [File.expand_path(path), source, VERSION, FORMAT_VERSION, config_digest].join("\0")
26
+ )
27
+ cache_path = File.join(@directory, "#{key}.json")
28
+ if File.file?(cache_path)
29
+ begin
30
+ return Source::Index.from_h(JSON.parse(File.read(cache_path)))
31
+ rescue StandardError
32
+ nil
33
+ end
34
+ end
35
+
36
+ value = yield(source)
37
+ begin
38
+ FileUtils.mkdir_p(@directory)
39
+ File.write(cache_path, JSON.generate(value.to_h))
40
+ rescue SystemCallError
41
+ nil
42
+ end
43
+ value
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module GraphQL
6
+ module Doctor
7
+ module Checks
8
+ class ArgumentKeywordMatch < Base
9
+ def call(field)
10
+ return [] unless field.status == :matched && field.source.confidence != :dynamic
11
+
12
+ owner, _name, method = method_target(field)
13
+ diagnostics = experimental_diagnostics(field, owner)
14
+ return diagnostics unless method && !method.dynamic
15
+
16
+ expected = expected_keywords(field)
17
+ diagnostics.concat(argument_diagnostics(field, method, expected))
18
+ diagnostics.concat(extra_diagnostics(field, method, expected[:extras]))
19
+ diagnostics.concat(prepare_diagnostics(field))
20
+ diagnostics.concat(callback_diagnostics(field, owner, expected))
21
+ diagnostics.compact
22
+ end
23
+
24
+ private
25
+
26
+ def expected_keywords(field)
27
+ always = []
28
+ maybe = []
29
+ field.runtime.fetch("arguments", []).each do |argument|
30
+ source = source_argument(field, argument)
31
+ present = argument.fetch("required", argument["non_null"]) || argument["has_default"] ||
32
+ source&.required == :nullable
33
+ (present ? always : maybe) << argument["keyword"].to_sym
34
+ end
35
+ {always: always, maybe: maybe, extras: field.runtime.fetch("extras", []).map(&:to_sym)}
36
+ end
37
+
38
+ def argument_diagnostics(field, method, expected)
39
+ accepted = method.required_keywords + method.optional_keywords
40
+ expected_arguments = expected[:always] + expected[:maybe]
41
+ diagnostics = []
42
+ unless method.accepts_keyword_rest
43
+ (expected_arguments - accepted).each do |keyword|
44
+ diagnostics << missing_keyword(field, method, keyword)
45
+ end
46
+ (method.required_keywords - expected_arguments - expected[:extras]).each do |keyword|
47
+ diagnostics << unexpected_required(field, keyword, expected_arguments)
48
+ end
49
+ if @config.check_enabled?("GQLD305")
50
+ (method.optional_keywords - expected_arguments - expected[:extras]).each do |keyword|
51
+ diagnostics << unexpected_optional(field, method, keyword)
52
+ end
53
+ end
54
+ end
55
+ (expected[:maybe] & method.required_keywords).each do |keyword|
56
+ diagnostics << required_optional(field, method, keyword)
57
+ end
58
+ diagnostics << required_positional(field, method) if method.required_positionals.to_i.positive?
59
+ diagnostics
60
+ end
61
+
62
+ def extra_diagnostics(field, method, extras)
63
+ return [] if method.accepts_keyword_rest
64
+
65
+ accepted = method.required_keywords + method.optional_keywords
66
+ (extras - accepted).filter_map do |extra|
67
+ diagnostic(
68
+ "GQLD306",
69
+ "GraphQL extra `#{extra}:` is not accepted by `##{method.name}`",
70
+ field.source.definition_location,
71
+ fingerprint: fingerprint(field, "GQLD306", extra)
72
+ )
73
+ end
74
+ end
75
+
76
+ def prepare_diagnostics(field)
77
+ field.runtime.fetch("arguments", []).filter_map do |argument|
78
+ name = argument["prepare"]
79
+ next unless name
80
+
81
+ owner, method, positional_count = prepare_target(field, argument, name)
82
+ next if valid_prepare?(method, positional_count, argument)
83
+
84
+ source = source_argument(field, argument)
85
+ diagnostic(
86
+ "GQLD205",
87
+ "Prepare method `#{owner}##{name}` must exist and accept one value plus optional context",
88
+ source&.definition_location || field.source.definition_location,
89
+ fingerprint: fingerprint(field, "GQLD205", name)
90
+ )
91
+ end
92
+ end
93
+
94
+ def prepare_target(field, argument, name)
95
+ source_location = argument["prepare_source_location"]
96
+ case argument["prepare_method_dispatch"]
97
+ when "object"
98
+ owner = field.runtime["owner"]
99
+ [owner, find_method(owner, name, source_location: source_location), 1]
100
+ when "resolver"
101
+ owner = field.runtime["resolver_class"]
102
+ [owner, find_singleton_method(owner, name, source_location: source_location), 2]
103
+ when nil
104
+ if argument.key?("prepare_method_dispatch")
105
+ return [field.runtime["resolver_class"] || field.runtime["owner"], nil, 0]
106
+ end
107
+
108
+ legacy_prepare_target(field, name, source_location)
109
+ end
110
+ end
111
+
112
+ def legacy_prepare_target(field, name, source_location)
113
+ owner = field.runtime["resolver_class"] || field.runtime["owner"]
114
+ method = if field.runtime["resolver_class"]
115
+ find_singleton_method(owner, name, source_location: source_location)
116
+ else
117
+ find_method(owner, name, source_location: source_location)
118
+ end
119
+ [owner, method, field.runtime["resolver_class"] ? 2 : 1]
120
+ end
121
+
122
+ def callback_diagnostics(field, owner, expected)
123
+ return [] unless field.runtime["resolver_class"]
124
+
125
+ locations = field.runtime.fetch("callback_source_locations", {})
126
+ signatures = field.runtime.fetch("callback_signatures", {})
127
+ %i[ready? authorized?].filter_map do |name|
128
+ method = reflected_method(
129
+ owner,
130
+ name,
131
+ source_location: locations[name.to_s],
132
+ signature: signatures[name.to_s],
133
+ fallback_location: field.source.definition_location
134
+ )
135
+ next unless method && !method.dynamic
136
+
137
+ accepted = method.required_keywords + method.optional_keywords
138
+ expected_keywords = expected[:always] + expected[:maybe] + expected[:extras]
139
+ missing = method.accepts_keyword_rest ? [] : expected_keywords - accepted
140
+ invalid_optional = expected[:maybe] & method.required_keywords
141
+ unexpected = method.required_keywords - expected_keywords
142
+ required_positionals = method.required_positionals || 0
143
+ next if missing.empty? && invalid_optional.empty? && unexpected.empty? && required_positionals.zero?
144
+
145
+ diagnostic(
146
+ "GQLD307",
147
+ "Callback `#{owner}##{name}` has an incompatible GraphQL argument signature",
148
+ method.location,
149
+ fingerprint: fingerprint(field, "GQLD307", name)
150
+ )
151
+ end
152
+ end
153
+
154
+ def accepts_positionals?(method, count)
155
+ required = method.required_positionals || method.positionals.length
156
+ optional = method.optional_positionals || 0
157
+ required <= count && (method.accepts_positional_rest || count <= required + optional)
158
+ end
159
+
160
+ def valid_prepare_method?(method, positional_count)
161
+ method && method.visibility == :public && method.required_keywords.empty? &&
162
+ accepts_positionals?(method, positional_count)
163
+ end
164
+
165
+ def valid_prepare?(method, positional_count, argument)
166
+ runtime_valid = argument["prepare_method_valid"]
167
+ return runtime_valid unless runtime_valid.nil?
168
+ return method.visibility == :public if method&.dynamic
169
+ return valid_prepare_method?(method, positional_count) if method
170
+ return argument["prepare_method_valid"] if argument.key?("prepare_method_valid")
171
+
172
+ argument["prepare_source_location"]
173
+ end
174
+
175
+ def experimental_diagnostics(field, owner)
176
+ return [] unless @config["experimental"].fetch("new_execution_api", false)
177
+
178
+ signatures = {
179
+ resolve_static: [:context],
180
+ resolve_batch: %i[objects context],
181
+ resolve_each: %i[object context]
182
+ }
183
+ signatures.filter_map do |name, required|
184
+ method = find_method(owner, name)
185
+ next unless method
186
+ next if method.required_keywords.sort == required.sort && method.accepts_keyword_rest
187
+
188
+ keywords = required.map { |key| "#{key}:" }.join(", ")
189
+ diagnostic(
190
+ "GQLD307",
191
+ "Experimental callback `#{owner}##{name}` must require #{keywords} and accept `**arguments`",
192
+ method.location,
193
+ fingerprint: fingerprint(field, "GQLD307", name)
194
+ )
195
+ end
196
+ end
197
+
198
+ def missing_keyword(field, method, keyword)
199
+ source = runtime_argument_source(field, keyword)
200
+ accepted = method.required_keywords + method.optional_keywords
201
+ suggestion = if source&.loads && accepted.include?(source.ruby_name)
202
+ source.ruby_name
203
+ else
204
+ nearest(accepted, keyword)
205
+ end
206
+ message = "GraphQL keyword `#{keyword}:` is not accepted by `##{method.name}`"
207
+ if source&.loads
208
+ message = "GraphQL argument `#{source.graphql_name}` is passed to Ruby as `#{keyword}:` after `loads:`"
209
+ end
210
+ suggestion_message = "Did you mean `#{keyword}:` instead of `#{suggestion}:`?" if suggestion
211
+ diagnostic(
212
+ "GQLD301",
213
+ message,
214
+ method.parameter_locations[suggestion] || source&.definition_location || field.source.definition_location,
215
+ suggestions: suggestion_message ? [Suggestion.new(message: suggestion_message)] : [],
216
+ fingerprint: fingerprint(field, "GQLD301", keyword)
217
+ )
218
+ end
219
+
220
+ def unexpected_required(field, keyword, expected)
221
+ method = method_target(field).last
222
+ suggestion = nearest(expected, keyword)
223
+ source_argument = field.source.arguments.find { |argument| argument.ruby_name == keyword }
224
+ loads_note = source_argument&.loads ? " (after `loads:` conversion)" : ""
225
+ diagnostic(
226
+ "GQLD302",
227
+ "Required Ruby keyword `#{keyword}:` has no GraphQL argument#{loads_note}",
228
+ method.parameter_locations.fetch(keyword, method.location),
229
+ suggestions: suggestion ? [Suggestion.new(message: "Did you mean `#{suggestion}:`?")] : [],
230
+ fingerprint: fingerprint(field, "GQLD302", keyword)
231
+ )
232
+ end
233
+
234
+ def required_optional(field, method, keyword)
235
+ suggestion = "Use `#{keyword}: nil`, add a GraphQL default, or accept `**arguments`."
236
+ diagnostic(
237
+ "GQLD303",
238
+ "GraphQL keyword `#{keyword}:` may be omitted but `##{method.name}` requires it",
239
+ method.parameter_locations.fetch(keyword, method.location),
240
+ suggestions: [Suggestion.new(message: suggestion)],
241
+ fingerprint: fingerprint(field, "GQLD303", keyword)
242
+ )
243
+ end
244
+
245
+ def unexpected_optional(field, method, keyword)
246
+ diagnostic(
247
+ "GQLD305",
248
+ "Optional Ruby keyword `#{keyword}:` has no known GraphQL argument",
249
+ method.parameter_locations.fetch(keyword, method.location),
250
+ fingerprint: fingerprint(field, "GQLD305", keyword)
251
+ )
252
+ end
253
+
254
+ def required_positional(field, method)
255
+ count = method.required_positionals
256
+ noun = count == 1 ? "argument" : "arguments"
257
+ diagnostic(
258
+ "GQLD302",
259
+ "Resolver method `##{method.name}` requires #{count} positional #{noun}, but GraphQL passes keywords only",
260
+ method.location || field.source.definition_location,
261
+ fingerprint: fingerprint(field, "GQLD302", "positional")
262
+ )
263
+ end
264
+
265
+ def source_argument(field, runtime_argument)
266
+ candidates = field.source.arguments
267
+ if field.runtime["resolver_class"]
268
+ candidates += @source_index.arguments.select do |argument|
269
+ argument.owner == field.runtime["resolver_class"] && argument.field_name.nil?
270
+ end
271
+ end
272
+ candidates.find do |argument|
273
+ argument.graphql_name == runtime_argument["graphql_name"] ||
274
+ argument.ruby_name.to_s == runtime_argument["keyword"]
275
+ end
276
+ end
277
+
278
+ def runtime_argument_source(field, keyword)
279
+ runtime = field.runtime.fetch("arguments", []).find { |argument| argument["keyword"] == keyword.to_s }
280
+ runtime && source_argument(field, runtime)
281
+ end
282
+
283
+ def nearest(candidates, keyword)
284
+ candidates.min_by { |candidate| levenshtein(candidate.to_s, keyword.to_s) }.then do |candidate|
285
+ candidate if candidate && levenshtein(candidate.to_s, keyword.to_s) <= 2
286
+ end
287
+ end
288
+
289
+ def levenshtein(left, right)
290
+ row = (0..right.length).to_a
291
+ left.each_char.with_index(1) do |left_char, index|
292
+ previous = row
293
+ row = [index]
294
+ right.each_char.with_index(1) do |right_char, other_index|
295
+ row << [row[-1] + 1, previous[other_index] + 1,
296
+ previous[other_index - 1] + (left_char == right_char ? 0 : 1)].min
297
+ end
298
+ end
299
+ row.last
300
+ end
301
+
302
+ def fingerprint(field, code, detail)
303
+ [field.source.owner, field.source.graphql_name, code, detail].join(":")
304
+ end
305
+ end
306
+ end
307
+ end
308
+ end