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
@@ -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