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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +71 -0
- data/Rakefile +9 -0
- data/docs/diagnostics/GQLD101.md +7 -0
- data/docs/diagnostics/GQLD102.md +7 -0
- data/docs/diagnostics/GQLD103.md +7 -0
- data/docs/diagnostics/GQLD104.md +7 -0
- data/docs/diagnostics/GQLD201.md +7 -0
- data/docs/diagnostics/GQLD202.md +7 -0
- data/docs/diagnostics/GQLD203.md +7 -0
- data/docs/diagnostics/GQLD204.md +7 -0
- data/docs/diagnostics/GQLD205.md +7 -0
- data/docs/diagnostics/GQLD301.md +7 -0
- data/docs/diagnostics/GQLD302.md +7 -0
- data/docs/diagnostics/GQLD303.md +7 -0
- data/docs/diagnostics/GQLD305.md +7 -0
- data/docs/diagnostics/GQLD306.md +7 -0
- data/docs/diagnostics/GQLD307.md +7 -0
- data/docs/diagnostics/GQLD401.md +7 -0
- data/docs/diagnostics/README.md +29 -0
- data/exe/graphql-doctor +6 -0
- data/lib/graphql/doctor/cache.rb +47 -0
- data/lib/graphql/doctor/checks/argument_keyword_match.rb +308 -0
- data/lib/graphql/doctor/checks/base.rb +121 -0
- data/lib/graphql/doctor/checks/engine.rb +74 -0
- data/lib/graphql/doctor/checks/resolver_method_presence.rb +68 -0
- data/lib/graphql/doctor/cli.rb +227 -0
- data/lib/graphql/doctor/config.rb +168 -0
- data/lib/graphql/doctor/correlation/correlator.rb +58 -0
- data/lib/graphql/doctor/diagnostic.rb +51 -0
- data/lib/graphql/doctor/ir.rb +30 -0
- data/lib/graphql/doctor/location.rb +52 -0
- data/lib/graphql/doctor/reporters/github.rb +34 -0
- data/lib/graphql/doctor/reporters/json.rb +19 -0
- data/lib/graphql/doctor/reporters/sarif.rb +82 -0
- data/lib/graphql/doctor/reporters/text.rb +47 -0
- data/lib/graphql/doctor/runner.rb +71 -0
- data/lib/graphql/doctor/runtime/dump.rb +137 -0
- data/lib/graphql/doctor/runtime/reflector.rb +313 -0
- data/lib/graphql/doctor/runtime/schema_loader.rb +40 -0
- data/lib/graphql/doctor/source/file_visitor.rb +366 -0
- data/lib/graphql/doctor/source/index.rb +164 -0
- data/lib/graphql/doctor/source/loader.rb +146 -0
- data/lib/graphql/doctor/source/name_mangler.rb +31 -0
- data/lib/graphql/doctor/suppression.rb +39 -0
- data/lib/graphql/doctor/version.rb +7 -0
- data/lib/graphql/doctor.rb +23 -0
- 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,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.
|
data/exe/graphql-doctor
ADDED
|
@@ -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
|