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