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,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Doctor
|
|
5
|
+
module Correlation
|
|
6
|
+
CorrelatedField = Struct.new(:source, :runtime, :status, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
class Correlator
|
|
9
|
+
attr_reader :fields
|
|
10
|
+
|
|
11
|
+
def initialize(source_index, runtime_ir)
|
|
12
|
+
@source_index = source_index
|
|
13
|
+
@runtime_fields = runtime_ir.fetch("types", []).flat_map { |type| type.fetch("fields", []) }
|
|
14
|
+
@fields = correlate
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def matched
|
|
18
|
+
fields.select { |field| field.status == :matched }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def runtime_only
|
|
22
|
+
fields.select { |field| field.status == :runtime_only }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def source_only
|
|
26
|
+
fields.select { |field| field.status == :source_only }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def mapped_field_ratio
|
|
30
|
+
denominator = matched.length + runtime_only.length
|
|
31
|
+
denominator.zero? ? 1.0 : matched.length.fdiv(denominator)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def correlate
|
|
37
|
+
remaining = @source_index.fields.dup
|
|
38
|
+
correlations = @runtime_fields.map do |runtime|
|
|
39
|
+
source = find_source(remaining, runtime)
|
|
40
|
+
remaining.delete(source) if source
|
|
41
|
+
CorrelatedField.new(source: source, runtime: runtime, status: source ? :matched : :runtime_only)
|
|
42
|
+
end
|
|
43
|
+
correlations + remaining.map do |source|
|
|
44
|
+
CorrelatedField.new(source: source, runtime: nil, status: :source_only)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def find_source(sources, runtime)
|
|
49
|
+
sources.find do |source|
|
|
50
|
+
source.owner == runtime["owner"] && source.ruby_name.to_s == runtime["method_sym"]
|
|
51
|
+
end || sources.find do |source|
|
|
52
|
+
source.owner == runtime["owner"] && source.graphql_name == runtime["graphql_name"]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Doctor
|
|
5
|
+
SEVERITY_RANK = {"error" => 3, "warning" => 2, "info" => 1}.freeze
|
|
6
|
+
|
|
7
|
+
Suggestion = Struct.new(:message, :replacement, :location, keyword_init: true) do
|
|
8
|
+
def initialize(**attributes)
|
|
9
|
+
super
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def to_h
|
|
14
|
+
{message: message, replacement: replacement, location: location&.to_h}.compact
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
Diagnostic = Struct.new(
|
|
19
|
+
:code, :severity, :message, :location, :suggestions, :notes, :fingerprint,
|
|
20
|
+
keyword_init: true
|
|
21
|
+
) do
|
|
22
|
+
def initialize(suggestions: [], notes: [], **attributes)
|
|
23
|
+
super(suggestions: suggestions.freeze, notes: notes.freeze, **attributes)
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.from_h(hash)
|
|
28
|
+
values = hash.transform_keys(&:to_sym)
|
|
29
|
+
values[:location] = Location.from_h(values[:location]) if values[:location]
|
|
30
|
+
values[:suggestions] = Array(values[:suggestions]).map do |suggestion|
|
|
31
|
+
suggestion = suggestion.transform_keys(&:to_sym)
|
|
32
|
+
suggestion[:location] = Location.from_h(suggestion[:location]) if suggestion[:location]
|
|
33
|
+
Suggestion.new(**suggestion)
|
|
34
|
+
end
|
|
35
|
+
new(**values)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_h
|
|
39
|
+
{
|
|
40
|
+
code: code,
|
|
41
|
+
severity: severity.to_s,
|
|
42
|
+
message: message,
|
|
43
|
+
location: location&.to_h,
|
|
44
|
+
suggestions: suggestions.map(&:to_h),
|
|
45
|
+
notes: notes,
|
|
46
|
+
fingerprint: fingerprint
|
|
47
|
+
}.compact
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Doctor
|
|
5
|
+
module IR
|
|
6
|
+
TypeDefinition = Struct.new(:owner, :superclass, :kind, :location, :confidence, keyword_init: true)
|
|
7
|
+
FieldDefinition = Struct.new(
|
|
8
|
+
:owner, :graphql_name, :ruby_name, :type_expression, :options, :arguments,
|
|
9
|
+
:block_location, :definition_location, :confidence,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
)
|
|
12
|
+
ArgumentDefinition = Struct.new(
|
|
13
|
+
:owner, :field_name, :graphql_name, :ruby_name, :type_expression, :required,
|
|
14
|
+
:default_value, :loads, :as, :prepare, :extras, :definition_location, :confidence,
|
|
15
|
+
keyword_init: true
|
|
16
|
+
)
|
|
17
|
+
MethodDefinition = Struct.new(
|
|
18
|
+
:owner, :name, :visibility, :singleton, :required_keywords, :optional_keywords,
|
|
19
|
+
:keyword_defaults, :accepts_keyword_rest, :forbids_keywords, :positionals,
|
|
20
|
+
:location, :parameter_locations, :dynamic, :required_positionals, :optional_positionals,
|
|
21
|
+
:accepts_positional_rest,
|
|
22
|
+
keyword_init: true
|
|
23
|
+
)
|
|
24
|
+
InterfaceImplementation = Struct.new(:owner, :interface, :location, keyword_init: true)
|
|
25
|
+
MutationRegistration = Struct.new(:owner, :mutation, :location, keyword_init: true)
|
|
26
|
+
DynamicDefinition = Struct.new(:owner, :kind, :reason, :location, keyword_init: true)
|
|
27
|
+
SourceComment = Struct.new(:text, :location, keyword_init: true)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Doctor
|
|
7
|
+
Location = Struct.new(
|
|
8
|
+
:path, :start_line, :end_line, :start_column, :end_column, :start_offset, :end_offset,
|
|
9
|
+
keyword_init: true
|
|
10
|
+
) do
|
|
11
|
+
def initialize(**attributes)
|
|
12
|
+
super
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_prism(path, location)
|
|
17
|
+
new(
|
|
18
|
+
path: path,
|
|
19
|
+
start_line: location.start_line,
|
|
20
|
+
end_line: location.end_line,
|
|
21
|
+
start_column: location.start_column,
|
|
22
|
+
end_column: location.end_column,
|
|
23
|
+
start_offset: location.start_offset,
|
|
24
|
+
end_offset: location.end_offset
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.from_h(hash)
|
|
29
|
+
new(**hash.transform_keys(&:to_sym))
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def to_h
|
|
33
|
+
members.to_h { |name| [name, public_send(name)] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def start_code_units_column(encoding = Encoding::UTF_16LE)
|
|
37
|
+
code_units_columns(encoding).first
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def end_code_units_column(encoding = Encoding::UTF_16LE)
|
|
41
|
+
code_units_columns(encoding).last
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def code_units_columns(encoding = Encoding::UTF_16LE, source: nil)
|
|
45
|
+
source ||= Prism.parse(File.binread(path)).source
|
|
46
|
+
[source.code_units_column(start_offset, encoding), source.code_units_column(end_offset, encoding)]
|
|
47
|
+
rescue SystemCallError, EncodingError, RuntimeError
|
|
48
|
+
[start_column, end_column]
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Doctor
|
|
5
|
+
module Reporters
|
|
6
|
+
class Github
|
|
7
|
+
LEVELS = {"error" => "error", "warning" => "warning", "info" => "notice"}.freeze
|
|
8
|
+
|
|
9
|
+
def initialize(io:, **)
|
|
10
|
+
@io = io
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def report(diagnostics)
|
|
14
|
+
diagnostics.each do |diagnostic|
|
|
15
|
+
location = diagnostic.location
|
|
16
|
+
properties = "file=#{escape_property(location.path)},line=#{location.start_line}," \
|
|
17
|
+
"col=#{location.start_column + 1},title=#{diagnostic.code}"
|
|
18
|
+
@io.puts "::#{LEVELS.fetch(diagnostic.severity)} #{properties}::#{escape_message(diagnostic.message)}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def escape_property(value)
|
|
25
|
+
escape_message(value).gsub(":", "%3A").gsub(",", "%2C")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def escape_message(value)
|
|
29
|
+
value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Doctor
|
|
7
|
+
module Reporters
|
|
8
|
+
class Json
|
|
9
|
+
def initialize(io:, **)
|
|
10
|
+
@io = io
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def report(diagnostics)
|
|
14
|
+
@io.puts JSON.pretty_generate(diagnostics.map(&:to_h))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module GraphQL
|
|
7
|
+
module Doctor
|
|
8
|
+
module Reporters
|
|
9
|
+
class Sarif
|
|
10
|
+
SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
11
|
+
|
|
12
|
+
def initialize(io:, **)
|
|
13
|
+
@io = io
|
|
14
|
+
@sources = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def report(diagnostics)
|
|
18
|
+
@io.puts JSON.pretty_generate(
|
|
19
|
+
"version" => "2.1.0",
|
|
20
|
+
"$schema" => SCHEMA,
|
|
21
|
+
"runs" => [{
|
|
22
|
+
"tool" => {"driver" => driver(diagnostics)},
|
|
23
|
+
"columnKind" => "unicodeCodePoints",
|
|
24
|
+
"results" => diagnostics.map { |diagnostic| result(diagnostic) }
|
|
25
|
+
}]
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def driver(diagnostics)
|
|
32
|
+
{
|
|
33
|
+
"name" => "graphql-doctor",
|
|
34
|
+
"version" => VERSION,
|
|
35
|
+
"informationUri" => "https://github.com/ydah/graphql-doctor",
|
|
36
|
+
"rules" => diagnostics.uniq(&:code).map do |diagnostic|
|
|
37
|
+
{
|
|
38
|
+
"id" => diagnostic.code,
|
|
39
|
+
"shortDescription" => {"text" => diagnostic.message.lines.first.chomp},
|
|
40
|
+
"helpUri" => "https://github.com/ydah/graphql-doctor/blob/main/docs/diagnostics/#{diagnostic.code}.md"
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def result(diagnostic)
|
|
47
|
+
location = diagnostic.location
|
|
48
|
+
start_column, end_column = code_units_columns(location)
|
|
49
|
+
{
|
|
50
|
+
"ruleId" => diagnostic.code,
|
|
51
|
+
"level" => {"error" => "error", "warning" => "warning", "info" => "note"}.fetch(diagnostic.severity),
|
|
52
|
+
"message" => {"text" => diagnostic.message},
|
|
53
|
+
"locations" => [{
|
|
54
|
+
"physicalLocation" => {
|
|
55
|
+
"artifactLocation" => {"uri" => location.path},
|
|
56
|
+
"region" => {
|
|
57
|
+
"startLine" => location.start_line,
|
|
58
|
+
"startColumn" => start_column + 1,
|
|
59
|
+
"endLine" => location.end_line,
|
|
60
|
+
"endColumn" => end_column + 1
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}],
|
|
64
|
+
"partialFingerprints" => {"primaryLocationLineHash" => fingerprint(diagnostic)}
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fingerprint(diagnostic)
|
|
69
|
+
value = diagnostic.fingerprint || [diagnostic.code, diagnostic.location.path, diagnostic.message].join(":")
|
|
70
|
+
Digest::SHA256.hexdigest(value)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def code_units_columns(location)
|
|
74
|
+
source = @sources[location.path] ||= Prism.parse(File.binread(location.path)).source
|
|
75
|
+
location.code_units_columns(Encoding::UTF_32LE, source: source)
|
|
76
|
+
rescue SystemCallError
|
|
77
|
+
[location.start_column, location.end_column]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Doctor
|
|
5
|
+
module Reporters
|
|
6
|
+
class Text
|
|
7
|
+
def initialize(io:, color: true)
|
|
8
|
+
@io = io
|
|
9
|
+
@color = color && !ENV.key?("NO_COLOR")
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def report(diagnostics)
|
|
13
|
+
diagnostics.each { |diagnostic| render(diagnostic) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def render(diagnostic)
|
|
19
|
+
location = diagnostic.location
|
|
20
|
+
@io.puts "#{location.path}:#{location.start_line}:#{location.start_column + 1}: " \
|
|
21
|
+
"#{paint(diagnostic.severity, diagnostic.severity)} #{diagnostic.code}: #{diagnostic.message}"
|
|
22
|
+
render_source(location)
|
|
23
|
+
diagnostic.suggestions.each { |suggestion| @io.puts " #{suggestion.message}" }
|
|
24
|
+
diagnostic.notes.each { |note| @io.puts " Note: #{note}" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def render_source(location)
|
|
28
|
+
line = File.readlines(location.path, chomp: true)[location.start_line - 1]
|
|
29
|
+
return unless line
|
|
30
|
+
|
|
31
|
+
width = [location.end_column - location.start_column, 1].max
|
|
32
|
+
@io.puts " #{line}"
|
|
33
|
+
@io.puts " #{' ' * location.start_column}#{'^' * width}"
|
|
34
|
+
rescue Errno::ENOENT
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def paint(text, severity)
|
|
39
|
+
return text unless @color
|
|
40
|
+
|
|
41
|
+
code = {"error" => 31, "warning" => 33, "info" => 36}.fetch(severity.to_s, 0)
|
|
42
|
+
"\e[#{code}m#{text}\e[0m"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GraphQL
|
|
4
|
+
module Doctor
|
|
5
|
+
class Runner
|
|
6
|
+
Result = Struct.new(:diagnostics, :runtime_error, keyword_init: true)
|
|
7
|
+
|
|
8
|
+
def initialize(config:, root: Dir.pwd, cache: true, jobs: 1, boot: true, schema_dump: nil)
|
|
9
|
+
@config = config
|
|
10
|
+
@root = root
|
|
11
|
+
@cache = cache
|
|
12
|
+
@jobs = jobs
|
|
13
|
+
@boot = boot
|
|
14
|
+
@schema_dump = schema_dump
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def check(paths = [])
|
|
18
|
+
source = Source::Loader.new(root: @root, config: @config, cache: @cache).load(paths, jobs: @jobs)
|
|
19
|
+
runtime = load_runtime(source)
|
|
20
|
+
return Result.new(diagnostics: static_diagnostics(source), runtime_error: false) unless runtime
|
|
21
|
+
|
|
22
|
+
correlation = Correlation::Correlator.new(source, runtime)
|
|
23
|
+
diagnostics = Checks::Engine.new(source, runtime, correlation, @config).call
|
|
24
|
+
diagnostics = Suppression.filter(
|
|
25
|
+
source.diagnostics + diagnostics,
|
|
26
|
+
source.comments,
|
|
27
|
+
require_reason: @config["require_suppression_reason"]
|
|
28
|
+
)
|
|
29
|
+
Result.new(diagnostics: diagnostics, runtime_error: false)
|
|
30
|
+
rescue Error => e
|
|
31
|
+
location = Location.new(
|
|
32
|
+
path: @config["require"].to_s, start_line: 1, end_line: 1,
|
|
33
|
+
start_column: 0, end_column: 1, start_offset: 0, end_offset: 1
|
|
34
|
+
)
|
|
35
|
+
message = e.message.sub(/\AGQLD101:\s*/, "")
|
|
36
|
+
diagnostic = Diagnostic.new(code: "GQLD101", severity: "error", message: message, location: location)
|
|
37
|
+
diagnostics = defined?(source) && source ? static_diagnostics(source) : []
|
|
38
|
+
Result.new(diagnostics: diagnostics + [diagnostic], runtime_error: true)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def load_runtime(source)
|
|
44
|
+
return Runtime::Dump.read(@schema_dump) if @schema_dump
|
|
45
|
+
return unless @boot
|
|
46
|
+
|
|
47
|
+
schema = Runtime::SchemaLoader.load(@config, root: @root)
|
|
48
|
+
Runtime::Reflector.new(
|
|
49
|
+
schema, source_index: source, abstract_classes: @config["abstract_classes"]
|
|
50
|
+
).call
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def static_diagnostics(source)
|
|
54
|
+
diagnostics = source.diagnostics.dup
|
|
55
|
+
if @config.check_enabled?("GQLD103")
|
|
56
|
+
diagnostics.concat(source.dynamics.map do |definition|
|
|
57
|
+
Diagnostic.new(
|
|
58
|
+
code: "GQLD103",
|
|
59
|
+
severity: @config.severity_for("GQLD103", "info"),
|
|
60
|
+
message: "Dynamic #{definition.kind} definition could not be analyzed",
|
|
61
|
+
location: definition.location
|
|
62
|
+
)
|
|
63
|
+
end)
|
|
64
|
+
end
|
|
65
|
+
Suppression.filter(
|
|
66
|
+
diagnostics, source.comments, require_reason: @config["require_suppression_reason"]
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module GraphQL
|
|
6
|
+
module Doctor
|
|
7
|
+
module Runtime
|
|
8
|
+
module Dump
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def write(runtime_ir, io)
|
|
12
|
+
io.write(JSON.pretty_generate(runtime_ir))
|
|
13
|
+
io.write("\n")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def read(path)
|
|
17
|
+
value = JSON.parse(File.read(path))
|
|
18
|
+
raise Error, "Schema dump must be an object" unless value.is_a?(Hash)
|
|
19
|
+
raise Error, "Unsupported schema dump version: #{value['version']}" unless value["version"] == 1
|
|
20
|
+
raise Error, "Schema dump types must be an array" unless value["types"].is_a?(Array)
|
|
21
|
+
|
|
22
|
+
validate_types(value["types"])
|
|
23
|
+
orphans = value.fetch("orphan_members", [])
|
|
24
|
+
unless orphans.is_a?(Array) && orphans.all?(String)
|
|
25
|
+
raise Error, "Schema dump orphan_members must be an array of strings"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
value
|
|
29
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
30
|
+
raise Error, "Invalid schema dump: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate_types(types)
|
|
34
|
+
types.each { |type| validate_type(type) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate_type(type)
|
|
38
|
+
raise Error, "Schema dump type must be an object" unless type.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
fields = type.fetch("fields", [])
|
|
41
|
+
raise Error, "Schema dump fields must be an array" unless fields.is_a?(Array)
|
|
42
|
+
|
|
43
|
+
fields.each { |field| validate_field(field) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate_field(field)
|
|
47
|
+
keys = %w[owner graphql_name method_sym resolver_method]
|
|
48
|
+
valid = field.is_a?(Hash) && keys.all? { |key| field[key].is_a?(String) }
|
|
49
|
+
raise Error, "Schema dump field is invalid" unless valid
|
|
50
|
+
|
|
51
|
+
validate_field_values(field)
|
|
52
|
+
|
|
53
|
+
arguments = field.fetch("arguments", [])
|
|
54
|
+
validate_field_collections(arguments, field.fetch("extras", []), field.fetch("extensions", []))
|
|
55
|
+
return if arguments.all? { |argument| valid_argument?(argument) }
|
|
56
|
+
|
|
57
|
+
raise Error, "Schema dump argument is invalid"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def validate_field_values(field)
|
|
61
|
+
unless %w[resolver_class type hash_key underlying_object_class].all? do |key|
|
|
62
|
+
optional_string?(field, key)
|
|
63
|
+
end
|
|
64
|
+
raise Error, "Schema dump field values are invalid"
|
|
65
|
+
end
|
|
66
|
+
unless source_location?(field["resolver_source_location"]) &&
|
|
67
|
+
optional_boolean?(field, "underlying_method_defined") &&
|
|
68
|
+
method_signature?(field["resolver_signature"]) &&
|
|
69
|
+
callback_locations?(field) && callback_signatures?(field)
|
|
70
|
+
raise Error, "Schema dump field values are invalid"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_field_collections(arguments, extras, extensions)
|
|
75
|
+
unless arguments.is_a?(Array) && [extras, extensions].all? do |items|
|
|
76
|
+
items.is_a?(Array) && items.all?(String)
|
|
77
|
+
end
|
|
78
|
+
raise Error, "Schema dump field collections must be arrays"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def valid_argument?(argument)
|
|
83
|
+
return false unless argument.is_a?(Hash)
|
|
84
|
+
return false unless %w[graphql_name keyword].all? { |key| argument[key].is_a?(String) }
|
|
85
|
+
return false unless %w[type loads as prepare].all? { |key| optional_string?(argument, key) }
|
|
86
|
+
return false unless source_location?(argument["prepare_source_location"])
|
|
87
|
+
return false unless optional_boolean?(argument, "prepare_method_valid")
|
|
88
|
+
return false unless [nil, "object", "resolver"].include?(argument["prepare_method_dispatch"])
|
|
89
|
+
|
|
90
|
+
%w[non_null required has_default].all? do |key|
|
|
91
|
+
!argument.key?(key) || argument[key] == true || argument[key] == false
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def optional_string?(value, key)
|
|
96
|
+
!value.key?(key) || value[key].nil? || value[key].is_a?(String)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def optional_boolean?(value, key)
|
|
100
|
+
!value.key?(key) || value[key].nil? || value[key] == true || value[key] == false
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def source_location?(value)
|
|
104
|
+
value.nil? || (value.is_a?(Array) && value.length == 2 && value[0].is_a?(String) && value[1].is_a?(Integer))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def callback_locations?(field)
|
|
108
|
+
callbacks = field.fetch("callback_source_locations", {})
|
|
109
|
+
callbacks.is_a?(Hash) && callbacks.keys.all? { |name| %w[ready? authorized?].include?(name) } &&
|
|
110
|
+
callbacks.values.all? { |location| source_location?(location) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def callback_signatures?(field)
|
|
114
|
+
callbacks = field.fetch("callback_signatures", {})
|
|
115
|
+
callbacks.is_a?(Hash) && callbacks.keys.all? { |name| %w[ready? authorized?].include?(name) } &&
|
|
116
|
+
callbacks.values.all? { |signature| method_signature?(signature) }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def method_signature?(signature)
|
|
120
|
+
return true if signature.nil?
|
|
121
|
+
return false unless signature.is_a?(Hash)
|
|
122
|
+
return false unless %w[public protected private].include?(signature["visibility"])
|
|
123
|
+
return false unless %w[required_keywords optional_keywords].all? do |key|
|
|
124
|
+
signature[key].is_a?(Array) && signature[key].all?(String)
|
|
125
|
+
end
|
|
126
|
+
return false unless %w[accepts_keyword_rest accepts_positional_rest].all? do |key|
|
|
127
|
+
[true, false].include?(signature[key])
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
%w[required_positionals optional_positionals].all? do |key|
|
|
131
|
+
signature[key].is_a?(Integer) && signature[key] >= 0
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|