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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ir"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Doctor
|
|
7
|
+
module Checks
|
|
8
|
+
SEVERITIES = {
|
|
9
|
+
"GQLD102" => "info", "GQLD103" => "info",
|
|
10
|
+
"GQLD201" => "warning", "GQLD204" => "warning", "GQLD401" => "warning",
|
|
11
|
+
"GQLD202" => "error", "GQLD203" => "error", "GQLD205" => "error",
|
|
12
|
+
"GQLD301" => "error", "GQLD302" => "error", "GQLD303" => "error",
|
|
13
|
+
"GQLD305" => "info", "GQLD306" => "error", "GQLD307" => "error"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
class Base
|
|
17
|
+
def initialize(source_index, config)
|
|
18
|
+
@source_index = source_index
|
|
19
|
+
@config = config
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def diagnostic(code, message, location, suggestions: [], notes: [], fingerprint: nil)
|
|
25
|
+
return unless @config.check_enabled?(code)
|
|
26
|
+
|
|
27
|
+
Diagnostic.new(
|
|
28
|
+
code: code,
|
|
29
|
+
severity: @config.severity_for(code, SEVERITIES.fetch(code)),
|
|
30
|
+
message: message,
|
|
31
|
+
location: location,
|
|
32
|
+
suggestions: suggestions,
|
|
33
|
+
notes: notes,
|
|
34
|
+
fingerprint: fingerprint
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def find_method(owner, name, source_location: nil)
|
|
39
|
+
method_at(source_location, name: name, singleton: false) ||
|
|
40
|
+
@source_index.method_definitions.reverse_each.find do |method|
|
|
41
|
+
method.owner == owner && method.name == name.to_sym && !method.singleton
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def find_singleton_method(owner, name, source_location: nil)
|
|
46
|
+
method_at(source_location, name: name, singleton: true) ||
|
|
47
|
+
@source_index.method_definitions.reverse_each.find do |method|
|
|
48
|
+
method.owner == owner && method.name == name.to_sym && method.singleton
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def method_target(field)
|
|
53
|
+
runtime = field.runtime
|
|
54
|
+
source = field.source
|
|
55
|
+
owner = runtime["resolver_class"] || source.owner
|
|
56
|
+
name = if runtime["resolver_class"] || source.options[:resolver_method] || source.options[:method]
|
|
57
|
+
runtime["resolver_method"]
|
|
58
|
+
else
|
|
59
|
+
runtime["method_sym"]
|
|
60
|
+
end
|
|
61
|
+
name = name.to_sym
|
|
62
|
+
method = reflected_method(
|
|
63
|
+
owner,
|
|
64
|
+
name,
|
|
65
|
+
source_location: runtime["resolver_source_location"],
|
|
66
|
+
signature: runtime["resolver_signature"],
|
|
67
|
+
fallback_location: source.definition_location
|
|
68
|
+
)
|
|
69
|
+
[owner, name, method]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reflected_method(owner, name, source_location:, signature:, fallback_location:)
|
|
73
|
+
source_method = method_at(source_location, name: name, singleton: false)
|
|
74
|
+
if signature
|
|
75
|
+
return runtime_method(
|
|
76
|
+
owner, name, signature,
|
|
77
|
+
source_method&.location || fallback_location,
|
|
78
|
+
source_method&.parameter_locations || {}
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
source_method || find_method(owner, name, source_location: source_location)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def runtime_method(owner, name, signature, location, parameter_locations)
|
|
86
|
+
IR::MethodDefinition.new(
|
|
87
|
+
owner: owner,
|
|
88
|
+
name: name.to_sym,
|
|
89
|
+
visibility: signature.fetch("visibility").to_sym,
|
|
90
|
+
singleton: false,
|
|
91
|
+
required_keywords: signature.fetch("required_keywords").map(&:to_sym),
|
|
92
|
+
optional_keywords: signature.fetch("optional_keywords").map(&:to_sym),
|
|
93
|
+
keyword_defaults: {},
|
|
94
|
+
accepts_keyword_rest: signature.fetch("accepts_keyword_rest"),
|
|
95
|
+
forbids_keywords: false,
|
|
96
|
+
positionals: [],
|
|
97
|
+
location: location,
|
|
98
|
+
parameter_locations: parameter_locations,
|
|
99
|
+
dynamic: false,
|
|
100
|
+
required_positionals: signature.fetch("required_positionals"),
|
|
101
|
+
optional_positionals: signature.fetch("optional_positionals"),
|
|
102
|
+
accepts_positional_rest: signature.fetch("accepts_positional_rest")
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def method_at(source_location, name:, singleton:)
|
|
107
|
+
return unless source_location
|
|
108
|
+
|
|
109
|
+
path, line = source_location
|
|
110
|
+
return unless path && line
|
|
111
|
+
|
|
112
|
+
@source_index.method_definitions.find do |method|
|
|
113
|
+
location = method.location
|
|
114
|
+
method.name == name.to_sym && method.singleton == singleton && location && location.start_line == line &&
|
|
115
|
+
path.tr("\\", "/").end_with?(location.path.tr("\\", "/"))
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "argument_keyword_match"
|
|
4
|
+
require_relative "resolver_method_presence"
|
|
5
|
+
|
|
6
|
+
module GraphQL
|
|
7
|
+
module Doctor
|
|
8
|
+
module Checks
|
|
9
|
+
class Engine
|
|
10
|
+
def initialize(source_index, runtime_ir, correlation, config)
|
|
11
|
+
@source_index = source_index
|
|
12
|
+
@runtime_ir = runtime_ir
|
|
13
|
+
@correlation = correlation
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
checks = [
|
|
19
|
+
ResolverMethodPresence.new(@source_index, @config),
|
|
20
|
+
ArgumentKeywordMatch.new(@source_index, @config)
|
|
21
|
+
]
|
|
22
|
+
diagnostics = @correlation.matched.flat_map do |field|
|
|
23
|
+
checks.flat_map { |check| check.call(field) }
|
|
24
|
+
end
|
|
25
|
+
diagnostics.concat(source_only_diagnostics)
|
|
26
|
+
diagnostics.concat(dynamic_diagnostics)
|
|
27
|
+
diagnostics.concat(orphan_diagnostics)
|
|
28
|
+
diagnostics.compact
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def source_only_diagnostics
|
|
34
|
+
return [] unless @config.check_enabled?("GQLD102")
|
|
35
|
+
|
|
36
|
+
@correlation.source_only.filter_map do |field|
|
|
37
|
+
next if field.source.confidence == :dynamic
|
|
38
|
+
|
|
39
|
+
diagnostic("GQLD102", "Source field is absent from the runtime schema", field.source.definition_location)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def dynamic_diagnostics
|
|
44
|
+
return [] unless @config.check_enabled?("GQLD103")
|
|
45
|
+
|
|
46
|
+
@source_index.dynamics.filter_map do |definition|
|
|
47
|
+
diagnostic("GQLD103", "Dynamic #{definition.kind} definition could not be analyzed", definition.location)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def orphan_diagnostics
|
|
52
|
+
@runtime_ir.fetch("orphan_members", []).filter_map do |name|
|
|
53
|
+
type = @source_index.types.find { |definition| definition.owner == name }
|
|
54
|
+
next unless type
|
|
55
|
+
|
|
56
|
+
diagnostic("GQLD401", "Resolver or mutation `#{name}` is not registered in the schema", type.location)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def diagnostic(code, message, location)
|
|
61
|
+
return unless @config.check_enabled?(code)
|
|
62
|
+
|
|
63
|
+
Diagnostic.new(
|
|
64
|
+
code: code,
|
|
65
|
+
severity: @config.severity_for(code, SEVERITIES.fetch(code)),
|
|
66
|
+
message: message,
|
|
67
|
+
location: location,
|
|
68
|
+
fingerprint: [location.path, location.start_line, code].join(":")
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Doctor
|
|
7
|
+
module Checks
|
|
8
|
+
class ResolverMethodPresence < Base
|
|
9
|
+
def call(field)
|
|
10
|
+
return [] unless field.status == :matched && field.source.confidence != :dynamic
|
|
11
|
+
return [] if field.runtime["hash_key"] || field.runtime["dig_keys"]
|
|
12
|
+
return [] if field.source.options[:hash_key] || field.source.options[:dig]
|
|
13
|
+
|
|
14
|
+
owner, name, method = method_target(field)
|
|
15
|
+
return visibility_diagnostic(field, method) if method
|
|
16
|
+
return [] if field.runtime["resolver_source_location"]
|
|
17
|
+
return [] if field.runtime["underlying_method_defined"]
|
|
18
|
+
return [] if experimental_method?(owner)
|
|
19
|
+
return [] if @config["allow_underlying_object"].include?(field.source.owner)
|
|
20
|
+
|
|
21
|
+
[missing_diagnostic(field, owner, name)].compact
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def visibility_diagnostic(field, method)
|
|
27
|
+
return [] if method.visibility == :public
|
|
28
|
+
|
|
29
|
+
[diagnostic(
|
|
30
|
+
"GQLD204",
|
|
31
|
+
"Resolver method `#{method.owner}##{method.name}` is #{method.visibility}",
|
|
32
|
+
method.location,
|
|
33
|
+
fingerprint: fingerprint(field, "GQLD204")
|
|
34
|
+
)].compact
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def missing_diagnostic(field, owner, name)
|
|
38
|
+
options = field.source.options
|
|
39
|
+
if options[:resolver_method]
|
|
40
|
+
code = "GQLD202"
|
|
41
|
+
message = "Explicit resolver method `#{owner}##{name}` does not exist"
|
|
42
|
+
elsif field.runtime["resolver_class"]
|
|
43
|
+
code = "GQLD202"
|
|
44
|
+
message = "Resolver method `#{owner}##{name}` does not exist"
|
|
45
|
+
elsif options[:method] && field.runtime["underlying_object_class"]
|
|
46
|
+
code = "GQLD203"
|
|
47
|
+
target = field.runtime["underlying_object_class"]
|
|
48
|
+
message = "Explicit field method `#{target}##{field.runtime['method_sym']}` does not exist"
|
|
49
|
+
else
|
|
50
|
+
code = "GQLD201"
|
|
51
|
+
message = "Resolver method `#{owner}##{name}` was not found; the underlying object may provide it"
|
|
52
|
+
end
|
|
53
|
+
diagnostic(code, message, field.source.definition_location, fingerprint: fingerprint(field, code))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fingerprint(field, code)
|
|
57
|
+
[field.source.owner, field.source.graphql_name, code].join(":")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def experimental_method?(owner)
|
|
61
|
+
return false unless @config["experimental"].fetch("new_execution_api", false)
|
|
62
|
+
|
|
63
|
+
%i[resolve_static resolve_batch resolve_each].any? { |name| find_method(owner, name) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "etc"
|
|
5
|
+
require_relative "../doctor"
|
|
6
|
+
require_relative "reporters/text"
|
|
7
|
+
require_relative "reporters/json"
|
|
8
|
+
require_relative "reporters/sarif"
|
|
9
|
+
require_relative "reporters/github"
|
|
10
|
+
|
|
11
|
+
module GraphQL
|
|
12
|
+
module Doctor
|
|
13
|
+
class CLI
|
|
14
|
+
REPORTERS = {
|
|
15
|
+
"text" => Reporters::Text,
|
|
16
|
+
"json" => Reporters::Json,
|
|
17
|
+
"sarif" => Reporters::Sarif,
|
|
18
|
+
"github" => Reporters::Github
|
|
19
|
+
}.freeze
|
|
20
|
+
HELP = <<~TEXT
|
|
21
|
+
Usage: graphql-doctor COMMAND [options] [paths]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
check [paths] Check resolver contracts (default)
|
|
25
|
+
dump-schema Dump runtime schema as JSON
|
|
26
|
+
coverage Show source/runtime mapping coverage
|
|
27
|
+
explain CODE Explain a diagnostic
|
|
28
|
+
version Print the version
|
|
29
|
+
TEXT
|
|
30
|
+
|
|
31
|
+
def self.start(argv = ARGV, out: $stdout, err: $stderr)
|
|
32
|
+
new(argv, out: out, err: err).run
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(argv, out:, err:)
|
|
36
|
+
@argv = argv.dup
|
|
37
|
+
@out = out
|
|
38
|
+
@err = err
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def run
|
|
42
|
+
if %w[help --help -h].include?(@argv.first)
|
|
43
|
+
@out.puts(HELP)
|
|
44
|
+
return 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@argv[0] = {"--version" => "version", "-v" => "version"}.fetch(@argv.first, @argv.first)
|
|
48
|
+
command = @argv.first&.start_with?("-") ? "check" : (@argv.shift || "check")
|
|
49
|
+
case command
|
|
50
|
+
when "version"
|
|
51
|
+
@out.puts(VERSION)
|
|
52
|
+
0
|
|
53
|
+
when "help", "--help", "-h"
|
|
54
|
+
@out.puts(HELP)
|
|
55
|
+
0
|
|
56
|
+
when "check" then check
|
|
57
|
+
when "dump-schema"
|
|
58
|
+
dump_schema
|
|
59
|
+
0
|
|
60
|
+
when "coverage"
|
|
61
|
+
coverage
|
|
62
|
+
0
|
|
63
|
+
when "explain" then explain
|
|
64
|
+
else
|
|
65
|
+
@err.puts("Command #{command.inspect} is not implemented yet")
|
|
66
|
+
2
|
|
67
|
+
end
|
|
68
|
+
rescue Error, OptionParser::ParseError, SystemCallError => e
|
|
69
|
+
@err.puts(e.message)
|
|
70
|
+
2
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def check
|
|
76
|
+
values = options
|
|
77
|
+
return 0 unless values
|
|
78
|
+
|
|
79
|
+
config = Config.load(values[:config], required: values[:config_given])
|
|
80
|
+
config = config.with_only(values[:only]) if values[:only]
|
|
81
|
+
result = Runner.new(
|
|
82
|
+
config: config,
|
|
83
|
+
cache: values[:cache],
|
|
84
|
+
jobs: values[:jobs],
|
|
85
|
+
boot: values[:boot],
|
|
86
|
+
schema_dump: values[:schema_dump]
|
|
87
|
+
).check(@argv)
|
|
88
|
+
diagnostics = select_diagnostics(result.diagnostics, values[:only])
|
|
89
|
+
sort_diagnostics(diagnostics)
|
|
90
|
+
reporter(values[:format], output_io(values[:out]), values[:color]).report(diagnostics)
|
|
91
|
+
@err.puts("Runtime checks skipped: --no-boot") unless values[:boot] || values[:schema_dump]
|
|
92
|
+
return 2 if result.runtime_error
|
|
93
|
+
|
|
94
|
+
threshold = SEVERITY_RANK.fetch(values[:fail_level]) { raise Error, "Invalid fail level" }
|
|
95
|
+
diagnostics.any? { |diagnostic| SEVERITY_RANK.fetch(diagnostic.severity) >= threshold } ? 1 : 0
|
|
96
|
+
ensure
|
|
97
|
+
@output_file&.close
|
|
98
|
+
@output_file = nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def options
|
|
102
|
+
values = {
|
|
103
|
+
config: ".graphql-doctor.yml", config_given: false, out: nil, schema_dump: nil, boot: true,
|
|
104
|
+
cache: true, jobs: Etc.nprocessors, format: "text", only: nil,
|
|
105
|
+
fail_level: "error", color: true
|
|
106
|
+
}
|
|
107
|
+
parser = OptionParser.new
|
|
108
|
+
configure_options(parser, values)
|
|
109
|
+
catch(:help) { parser.parse!(@argv) } || (return nil)
|
|
110
|
+
validate_options(values)
|
|
111
|
+
values
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def configure_options(parser, values)
|
|
115
|
+
parser.banner = HELP.lines.first.chomp
|
|
116
|
+
parser.on("--config PATH") do |value|
|
|
117
|
+
values[:config] = value
|
|
118
|
+
values[:config_given] = true
|
|
119
|
+
end
|
|
120
|
+
parser.on("--out PATH") { |value| values[:out] = value }
|
|
121
|
+
parser.on("--schema-dump PATH") { |value| values[:schema_dump] = value }
|
|
122
|
+
parser.on("--no-boot") { values[:boot] = false }
|
|
123
|
+
parser.on("--[no-]cache") { |value| values[:cache] = value }
|
|
124
|
+
parser.on("--jobs N", Integer) { |value| values[:jobs] = value }
|
|
125
|
+
parser.on("--format FORMAT") { |value| values[:format] = value }
|
|
126
|
+
parser.on("--only CODES") { |value| values[:only] = value.split(",") }
|
|
127
|
+
parser.on("--fail-level LEVEL") { |value| values[:fail_level] = value }
|
|
128
|
+
parser.on("--[no-]color") { |value| values[:color] = value }
|
|
129
|
+
parser.on("--help") do
|
|
130
|
+
@out.puts(parser)
|
|
131
|
+
throw :help
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def validate_options(values)
|
|
136
|
+
raise Error, "Unknown output format: #{values[:format]}" unless REPORTERS.key?(values[:format])
|
|
137
|
+
raise Error, "Invalid fail level: #{values[:fail_level]}" unless SEVERITY_RANK.key?(values[:fail_level])
|
|
138
|
+
raise Error, "Jobs must be greater than zero" unless values[:jobs].positive?
|
|
139
|
+
|
|
140
|
+
unknown = Array(values[:only]) - Config::CHECK_CODES
|
|
141
|
+
raise Error, "Unknown diagnostic code(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def select_diagnostics(diagnostics, only)
|
|
145
|
+
return diagnostics unless only
|
|
146
|
+
|
|
147
|
+
selected = only + Config::FIXED_ERROR_CODES
|
|
148
|
+
diagnostics.select { |diagnostic| selected.include?(diagnostic.code) }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def sort_diagnostics(diagnostics)
|
|
152
|
+
diagnostics.sort_by! do |diagnostic|
|
|
153
|
+
[diagnostic.location.path, diagnostic.location.start_line, diagnostic.location.start_column, diagnostic.code]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def dump_schema
|
|
158
|
+
values = options
|
|
159
|
+
return 0 unless values
|
|
160
|
+
|
|
161
|
+
config = Config.load(values[:config], required: values[:config_given])
|
|
162
|
+
source = Source::Loader.new(config: config, cache: values[:cache]).load([], jobs: values[:jobs])
|
|
163
|
+
schema = Runtime::SchemaLoader.load(config)
|
|
164
|
+
runtime = Runtime::Reflector.new(
|
|
165
|
+
schema, source_index: source, abstract_classes: config["abstract_classes"]
|
|
166
|
+
).call
|
|
167
|
+
write_output(values[:out]) { |io| Runtime::Dump.write(runtime, io) }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def explain
|
|
171
|
+
code = @argv.shift.to_s.upcase
|
|
172
|
+
raise Error, "A diagnostic code is required" unless code.match?(/\AGQLD\d+\z/)
|
|
173
|
+
|
|
174
|
+
path = File.expand_path("../../../docs/diagnostics/#{code}.md", __dir__)
|
|
175
|
+
raise Error, "Unknown diagnostic code: #{code}" unless File.file?(path)
|
|
176
|
+
|
|
177
|
+
@out.write(File.read(path))
|
|
178
|
+
0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def coverage
|
|
182
|
+
values = options
|
|
183
|
+
return 0 unless values
|
|
184
|
+
|
|
185
|
+
config = Config.load(values[:config], required: values[:config_given])
|
|
186
|
+
source = Source::Loader.new(config: config, cache: values[:cache]).load(@argv, jobs: values[:jobs])
|
|
187
|
+
runtime = runtime_ir(values, config, source)
|
|
188
|
+
correlation = Correlation::Correlator.new(source, runtime)
|
|
189
|
+
write_output(values[:out]) do |io|
|
|
190
|
+
total = correlation.matched.length + correlation.runtime_only.length
|
|
191
|
+
io.puts format("Mapped fields: %<matched>d/%<total>d (%<ratio>.1f%%)",
|
|
192
|
+
matched: correlation.matched.length, total: total, ratio: correlation.mapped_field_ratio * 100)
|
|
193
|
+
correlation.runtime_only.each do |field|
|
|
194
|
+
io.puts "Unmapped runtime field: #{field.runtime['owner']}.#{field.runtime['graphql_name']}"
|
|
195
|
+
end
|
|
196
|
+
source.dynamics.each do |definition|
|
|
197
|
+
io.puts "Dynamic definition: #{definition.location.path}:#{definition.location.start_line}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def runtime_ir(values, config, source)
|
|
203
|
+
return Runtime::Dump.read(values[:schema_dump]) if values[:schema_dump]
|
|
204
|
+
raise Error, "--no-boot requires --schema-dump" unless values[:boot]
|
|
205
|
+
|
|
206
|
+
schema = Runtime::SchemaLoader.load(config)
|
|
207
|
+
Runtime::Reflector.new(schema, source_index: source, abstract_classes: config["abstract_classes"]).call
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def write_output(path, &)
|
|
211
|
+
return yield(@out) unless path
|
|
212
|
+
|
|
213
|
+
File.open(path, "w", &)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def output_io(path)
|
|
217
|
+
return @out unless path
|
|
218
|
+
|
|
219
|
+
@output_file = File.open(path, "w")
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def reporter(format, io, color)
|
|
223
|
+
REPORTERS.fetch(format).new(io: io, color: color)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Doctor
|
|
7
|
+
class Config
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
"schema" => nil,
|
|
10
|
+
"require" => "./config/environment",
|
|
11
|
+
"include" => ["app/graphql/**/*.rb"],
|
|
12
|
+
"exclude" => [],
|
|
13
|
+
"checks" => {
|
|
14
|
+
"GQLD102" => {"enabled" => false},
|
|
15
|
+
"GQLD103" => {"enabled" => false},
|
|
16
|
+
"GQLD201" => {"severity" => "warning"},
|
|
17
|
+
"GQLD305" => {"enabled" => false}
|
|
18
|
+
},
|
|
19
|
+
"allow_underlying_object" => [],
|
|
20
|
+
"abstract_classes" => [],
|
|
21
|
+
"experimental" => {"new_execution_api" => false},
|
|
22
|
+
"require_suppression_reason" => false
|
|
23
|
+
}.freeze
|
|
24
|
+
CHECK_CODES = %w[
|
|
25
|
+
GQLD101 GQLD102 GQLD103 GQLD104 GQLD201 GQLD202 GQLD203 GQLD204 GQLD205
|
|
26
|
+
GQLD301 GQLD302 GQLD303 GQLD305 GQLD306 GQLD307 GQLD401
|
|
27
|
+
].freeze
|
|
28
|
+
FIXED_ERROR_CODES = %w[GQLD101 GQLD104].freeze
|
|
29
|
+
CONFIGURABLE_CHECK_CODES = (CHECK_CODES - FIXED_ERROR_CODES).freeze
|
|
30
|
+
|
|
31
|
+
attr_reader :values
|
|
32
|
+
|
|
33
|
+
def initialize(values = {})
|
|
34
|
+
raise Error, "Configuration must be a mapping" unless values.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
unknown = values.keys.map(&:to_s) - DEFAULTS.keys
|
|
37
|
+
raise Error, "Unknown configuration key(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
38
|
+
|
|
39
|
+
normalized = values.to_h { |key, value| [key.to_s, value] }
|
|
40
|
+
normalized["checks"] = normalize_checks(normalized.fetch("checks", {}))
|
|
41
|
+
normalized["experimental"] = normalize_experimental(normalized.fetch("experimental", {}))
|
|
42
|
+
validate_values(normalized)
|
|
43
|
+
merged = DEFAULTS.merge(normalized)
|
|
44
|
+
merged["checks"] = DEFAULTS["checks"].merge(normalized.fetch("checks", {}))
|
|
45
|
+
@values = immutable(merged)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.load(path = ".graphql-doctor.yml", required: false)
|
|
49
|
+
unless File.file?(path)
|
|
50
|
+
raise Error, "Configuration file does not exist: #{path}" if required
|
|
51
|
+
|
|
52
|
+
return new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
content = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false) || {}
|
|
56
|
+
raise Error, "Configuration must be a mapping" unless content.is_a?(Hash)
|
|
57
|
+
|
|
58
|
+
new(content)
|
|
59
|
+
rescue Psych::Exception => e
|
|
60
|
+
raise Error, "Invalid configuration: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def [](key)
|
|
64
|
+
values.fetch(key.to_s)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_enabled?(code)
|
|
68
|
+
values["checks"].fetch(code, {}).fetch("enabled", true)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def severity_for(code, default)
|
|
72
|
+
values["checks"].fetch(code, {}).fetch("severity", default).to_s
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def with_only(codes)
|
|
76
|
+
checks = CONFIGURABLE_CHECK_CODES.to_h do |code|
|
|
77
|
+
settings = values["checks"].fetch(code, {}).merge("enabled" => codes.include?(code))
|
|
78
|
+
[code, settings]
|
|
79
|
+
end
|
|
80
|
+
self.class.new(values.merge("checks" => checks))
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def normalize_checks(checks)
|
|
86
|
+
raise Error, "Configuration key `checks` must be a mapping" unless checks.is_a?(Hash)
|
|
87
|
+
|
|
88
|
+
checks.to_h do |code, settings|
|
|
89
|
+
code = code.to_s
|
|
90
|
+
raise Error, "Unknown diagnostic code in configuration: #{code}" unless CHECK_CODES.include?(code)
|
|
91
|
+
raise Error, "Check settings for #{code} must be a mapping" unless settings.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
settings = settings.to_h { |key, value| [key.to_s, value] }
|
|
94
|
+
validate_check_settings(code, settings)
|
|
95
|
+
[code, settings]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_check_settings(code, settings)
|
|
100
|
+
if FIXED_ERROR_CODES.include?(code) && !settings.empty?
|
|
101
|
+
raise Error, "#{code} is an execution error and cannot be configured"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
unknown = settings.keys - %w[enabled severity]
|
|
105
|
+
raise Error, "Unknown check setting(s) for #{code}: #{unknown.join(', ')}" unless unknown.empty?
|
|
106
|
+
if settings.key?("severity") && !SEVERITY_RANK.key?(settings["severity"].to_s)
|
|
107
|
+
raise Error, "Invalid severity for #{code}: #{settings['severity']}"
|
|
108
|
+
end
|
|
109
|
+
return if !settings.key?("enabled") || [true, false].include?(settings["enabled"])
|
|
110
|
+
|
|
111
|
+
raise Error, "Invalid enabled value for #{code}: #{settings['enabled'].inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_experimental(settings)
|
|
115
|
+
raise Error, "Configuration key `experimental` must be a mapping" unless settings.is_a?(Hash)
|
|
116
|
+
|
|
117
|
+
normalized = settings.to_h { |key, value| [key.to_s, value] }
|
|
118
|
+
unknown = normalized.keys - ["new_execution_api"]
|
|
119
|
+
raise Error, "Unknown experimental setting(s): #{unknown.join(', ')}" unless unknown.empty?
|
|
120
|
+
|
|
121
|
+
value = normalized["new_execution_api"]
|
|
122
|
+
return normalized unless normalized.key?("new_execution_api")
|
|
123
|
+
return normalized if [true, false].include?(value)
|
|
124
|
+
|
|
125
|
+
raise Error, "Invalid experimental setting `new_execution_api`: #{value.inspect}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def validate_values(values)
|
|
129
|
+
validate_string_values(values)
|
|
130
|
+
validate_array_values(values)
|
|
131
|
+
return unless values.key?("require_suppression_reason")
|
|
132
|
+
return if [true, false].include?(values["require_suppression_reason"])
|
|
133
|
+
|
|
134
|
+
raise Error, "Configuration key `require_suppression_reason` must be boolean"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def validate_string_values(values)
|
|
138
|
+
%w[schema require].each do |key|
|
|
139
|
+
next unless values.key?(key)
|
|
140
|
+
next if values[key].nil? || values[key].is_a?(String)
|
|
141
|
+
|
|
142
|
+
raise Error, "Configuration key `#{key}` must be a string"
|
|
143
|
+
end
|
|
144
|
+
return unless values["schema"].is_a?(String) && values["schema"].strip.empty?
|
|
145
|
+
|
|
146
|
+
raise Error, "Configuration key `schema` must not be empty"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_array_values(values)
|
|
150
|
+
%w[include exclude allow_underlying_object abstract_classes].each do |key|
|
|
151
|
+
next unless values.key?(key)
|
|
152
|
+
next if values[key].is_a?(Array) && values[key].all?(String)
|
|
153
|
+
|
|
154
|
+
raise Error, "Configuration key `#{key}` must be an array of strings"
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def immutable(value)
|
|
159
|
+
case value
|
|
160
|
+
when Hash then value.transform_values { |item| immutable(item) }.freeze
|
|
161
|
+
when Array then value.map { |item| immutable(item) }.freeze
|
|
162
|
+
when String then value.dup.freeze
|
|
163
|
+
else value.freeze
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|