ripple_effect 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 (59) hide show
  1. checksums.yaml +7 -0
  2. data/.ripple-effect.yml.example +56 -0
  3. data/ARCHITECTURE.md +222 -0
  4. data/CHANGELOG.md +115 -0
  5. data/CODE_OF_CONDUCT.md +64 -0
  6. data/CONTRIBUTING.md +112 -0
  7. data/LICENSE.txt +21 -0
  8. data/README.md +305 -0
  9. data/SECURITY.md +73 -0
  10. data/docs/ANALYSIS_MODEL.md +275 -0
  11. data/docs/CLI.md +276 -0
  12. data/docs/CONFIGURATION.md +178 -0
  13. data/docs/DECISIONS.md +210 -0
  14. data/docs/PUBLIC_LAUNCH_CHECKLIST.md +105 -0
  15. data/docs/RELEASING.md +94 -0
  16. data/docs/TESTING.md +179 -0
  17. data/exe/ripple-effect +7 -0
  18. data/lib/ripple_effect/analyzer.rb +379 -0
  19. data/lib/ripple_effect/cache_store.rb +207 -0
  20. data/lib/ripple_effect/cli/application.rb +126 -0
  21. data/lib/ripple_effect/cli/command.rb +165 -0
  22. data/lib/ripple_effect/cli/diff_command.rb +76 -0
  23. data/lib/ripple_effect/cli/doctor_command.rb +106 -0
  24. data/lib/ripple_effect/cli/graph_command.rb +61 -0
  25. data/lib/ripple_effect/cli/inspect_command.rb +66 -0
  26. data/lib/ripple_effect/cli/tests_command.rb +109 -0
  27. data/lib/ripple_effect/cli/version_command.rb +46 -0
  28. data/lib/ripple_effect/confidence.rb +61 -0
  29. data/lib/ripple_effect/configuration.rb +264 -0
  30. data/lib/ripple_effect/diagnostic.rb +90 -0
  31. data/lib/ripple_effect/diff/changed_symbol_resolver.rb +292 -0
  32. data/lib/ripple_effect/diff/git.rb +175 -0
  33. data/lib/ripple_effect/diff/hunk.rb +80 -0
  34. data/lib/ripple_effect/edge.rb +114 -0
  35. data/lib/ripple_effect/error.rb +23 -0
  36. data/lib/ripple_effect/extractors/base.rb +292 -0
  37. data/lib/ripple_effect/extractors/rails_associations.rb +102 -0
  38. data/lib/ripple_effect/extractors/rails_callbacks.rb +144 -0
  39. data/lib/ripple_effect/extractors/rails_delegation.rb +121 -0
  40. data/lib/ripple_effect/extractors/rails_jobs.rb +131 -0
  41. data/lib/ripple_effect/extractors/rails_mailers.rb +120 -0
  42. data/lib/ripple_effect/extractors/rails_routes.rb +256 -0
  43. data/lib/ripple_effect/extractors/rails_views.rb +299 -0
  44. data/lib/ripple_effect/extractors/ruby_structure.rb +221 -0
  45. data/lib/ripple_effect/extractors/test_conventions.rb +135 -0
  46. data/lib/ripple_effect/formatters/dot.rb +69 -0
  47. data/lib/ripple_effect/formatters/json.rb +43 -0
  48. data/lib/ripple_effect/formatters/text.rb +197 -0
  49. data/lib/ripple_effect/graph.rb +199 -0
  50. data/lib/ripple_effect/node.rb +153 -0
  51. data/lib/ripple_effect/project.rb +264 -0
  52. data/lib/ripple_effect/result.rb +147 -0
  53. data/lib/ripple_effect/risk.rb +167 -0
  54. data/lib/ripple_effect/static_index/adapter.rb +84 -0
  55. data/lib/ripple_effect/static_index/rubydex_adapter.rb +356 -0
  56. data/lib/ripple_effect/traversal/impact_walker.rb +153 -0
  57. data/lib/ripple_effect/version.rb +11 -0
  58. data/lib/ripple_effect.rb +89 -0
  59. metadata +155 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+
5
+ module RippleEffect
6
+ module CLI
7
+ # `ripple-effect tests BASE [HEAD]`
8
+ #
9
+ # The `paths` format prints nothing but test paths on stdout so it composes:
10
+ #
11
+ # bundle exec rspec $(ripple-effect tests main)
12
+ #
13
+ # Every warning therefore goes to stderr. When a global or boot-impact file
14
+ # changed, a narrow list would be actively misleading, so the command refuses
15
+ # to print one unless the user overrides with --allow-unsafe-focus. Ripple Effect
16
+ # recommends tests; it never claims the rest are unnecessary.
17
+ class TestsCommand < Command
18
+ def banner
19
+ <<~BANNER
20
+ Usage: ripple-effect tests BASE [HEAD] [options]
21
+
22
+ Print the test files most likely to be relevant to a change.
23
+
24
+ Examples:
25
+ bundle exec rspec $(ripple-effect tests main)
26
+ ripple-effect tests origin/main --format json
27
+
28
+ Options:
29
+ BANNER
30
+ end
31
+
32
+ def run(arguments)
33
+ base, head = arguments
34
+
35
+ if base.nil?
36
+ stderr.puts "Error: tests requires a BASE revision (e.g. `ripple-effect tests main`)"
37
+ return USER_ERROR
38
+ end
39
+
40
+ result = analyzer.diff(base: base, head: head, min_confidence: @min_confidence)
41
+
42
+ return refuse(result) if result.unsafe_focus? && !@allow_unsafe_focus
43
+
44
+ emit_tests(result)
45
+ SUCCESS
46
+ end
47
+
48
+ private
49
+
50
+ def refuse(result)
51
+ stderr.puts "WARNING: focused tests may be incomplete because " \
52
+ "#{result.global_files.join(', ')} changed."
53
+ stderr.puts "These files affect application boot or configuration, so the impact graph " \
54
+ "cannot bound what they reach."
55
+ stderr.puts "Run your full suite, or pass --allow-unsafe-focus to print a narrowed list anyway."
56
+
57
+ if json?
58
+ stderr.print Formatters::Json.error(
59
+ RippleEffect::Error.new("unsafe focus: #{result.global_files.join(', ')} changed"),
60
+ code: "unsafe_focus"
61
+ )
62
+ end
63
+
64
+ USER_ERROR
65
+ end
66
+
67
+ def emit_tests(result)
68
+ case effective_format
69
+ when "json" then stdout.print Formatters::Json.new(result: result).render
70
+ when "text" then stdout.print Formatters::Text.new(result: result, verbose: options[:verbose]).render
71
+ else
72
+ warn_if_empty(result)
73
+ result.test_files.each { |path| stdout.puts path }
74
+ end
75
+ end
76
+
77
+ # Unlike every other command, this one defaults to bare paths so that
78
+ # `bundle exec rspec $(ripple-effect tests main)` works without flags.
79
+ def effective_format
80
+ options[:format_given] ? options[:format] : "paths"
81
+ end
82
+
83
+ def warn_if_empty(result)
84
+ return unless result.test_files.empty?
85
+
86
+ stderr.puts "No relevant test files found for #{result.query_value}. Run your full suite."
87
+ end
88
+
89
+ # This command defines its own --format with a different set of values.
90
+ def custom_format_option? = true
91
+
92
+ def define_options(parser)
93
+ parser.on("--runner RUNNER", %w[rspec minitest auto], "Test framework (default: auto)") do |value|
94
+ @runner = value.to_sym
95
+ end
96
+ parser.on("--allow-unsafe-focus", "Print a narrowed list even when a global file changed") do
97
+ @allow_unsafe_focus = true
98
+ end
99
+ parser.on("--min-confidence LEVEL", "Only follow edges at least this confident") do |value|
100
+ @min_confidence = parse_confidence(value)
101
+ end
102
+ parser.on("--format FORMAT", %w[paths text json], "paths (default), text, or json") do |value|
103
+ options[:format] = value
104
+ options[:format_given] = true
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+
5
+ module RippleEffect
6
+ module CLI
7
+ # `ripple-effect version`
8
+ #
9
+ # Prints the bare semantic version, so it can be captured in a shell variable
10
+ # without post-processing. `--verbose` adds environment details.
11
+ class VersionCommand < Command
12
+ def banner
13
+ <<~BANNER
14
+ Usage: ripple-effect version [options]
15
+
16
+ Print the Ripple Effect version.
17
+
18
+ Options:
19
+ BANNER
20
+ end
21
+
22
+ def run(_arguments)
23
+ if json?
24
+ stdout.print "#{JSON.pretty_generate(payload)}\n"
25
+ elsif options[:verbose]
26
+ payload.each { |key, value| stdout.puts "#{key}: #{value}" }
27
+ else
28
+ stdout.puts VERSION
29
+ end
30
+
31
+ SUCCESS
32
+ end
33
+
34
+ private
35
+
36
+ def payload
37
+ {
38
+ "ripple_effect" => VERSION,
39
+ "schema_version" => SCHEMA_VERSION,
40
+ "ruby" => RUBY_VERSION,
41
+ "static_index" => StaticIndex::RubydexAdapter.backend_version
42
+ }
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RippleEffect
4
+ # Confidence bands for graph edges.
5
+ #
6
+ # Three coarse bands rather than a numeric probability. Ruby is dynamic enough
7
+ # that a percentage would be false precision. The numeric +RANK+ is only used
8
+ # for ranking and traversal.
9
+ #
10
+ # - +:high+ explicit static reference, or a literal Rails DSL relationship
11
+ # - +:medium+ receiver or target inferred from a strong convention
12
+ # - +:low+ naming or path heuristic only
13
+ module Confidence
14
+ HIGH = :high
15
+ MEDIUM = :medium
16
+ LOW = :low
17
+
18
+ # Ordered from strongest to weakest.
19
+ ALL = [HIGH, MEDIUM, LOW].freeze
20
+
21
+ RANK = { HIGH => 3, MEDIUM => 2, LOW => 1 }.freeze
22
+
23
+ module_function
24
+
25
+ # @param value [Symbol, String]
26
+ # @return [Symbol] the canonical band
27
+ # @raise [ArgumentError] when the value is not a known band
28
+ def cast(value)
29
+ band = value.to_s.downcase.to_sym
30
+ return band if ALL.include?(band)
31
+
32
+ raise ArgumentError, "unknown confidence #{value.inspect}, expected one of #{ALL.join(', ')}"
33
+ end
34
+
35
+ # @return [Boolean] true when +value+ is a known band
36
+ def valid?(value)
37
+ ALL.include?(value.to_s.downcase.to_sym)
38
+ end
39
+
40
+ # @return [Integer] numeric rank, higher is more confident
41
+ def rank(value)
42
+ RANK.fetch(cast(value))
43
+ end
44
+
45
+ # @return [Boolean] true when +value+ is at least as confident as +minimum+
46
+ def at_least?(value, minimum)
47
+ rank(value) >= rank(minimum)
48
+ end
49
+
50
+ # The weakest of the given bands, which is how a path's confidence is derived:
51
+ # a chain of reasoning is only as trustworthy as its weakest link.
52
+ #
53
+ # @param bands [Array<Symbol>]
54
+ # @return [Symbol]
55
+ def weakest(bands)
56
+ return HIGH if bands.empty?
57
+
58
+ bands.min_by { |band| rank(band) }
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "digest"
5
+ require_relative "error"
6
+ require_relative "confidence"
7
+
8
+ module RippleEffect
9
+ # Merged view of the built-in defaults and the project's `.ripple-effect.yml`.
10
+ #
11
+ # Unknown top-level keys produce a warning ({#warnings}) rather than silence,
12
+ # while invalid values for a known key are a hard {ConfigurationError}: a typo in
13
+ # a key is usually harmless, but a bad value would silently change what we analyse.
14
+ class Configuration
15
+ CONFIG_FILENAME = ".ripple-effect.yml"
16
+ SCHEMA_VERSION = 1
17
+
18
+ DEFAULT_INCLUDE = [
19
+ "app/**/*.rb",
20
+ "lib/**/*.rb",
21
+ "config/routes.rb",
22
+ "config/routes/**/*.rb",
23
+ "spec/**/*.rb",
24
+ "test/**/*.rb"
25
+ ].freeze
26
+
27
+ # Templates are not Ruby files, so they need their own patterns. In a classic
28
+ # server-rendered Rails application the views are a large part of the real
29
+ # dependency graph, and leaving them out understates every helper's blast
30
+ # radius.
31
+ DEFAULT_VIEW_INCLUDE = [
32
+ "app/views/**/*.erb"
33
+ ].freeze
34
+
35
+ DEFAULT_EXCLUDE = [
36
+ "vendor/**",
37
+ "node_modules/**",
38
+ "tmp/**",
39
+ "log/**",
40
+ "coverage/**",
41
+ "public/assets/**",
42
+ "storage/**",
43
+ ".bundle/**",
44
+ ".git/**"
45
+ ].freeze
46
+
47
+ # Files whose change can affect nearly anything, so a narrow answer would be
48
+ # misleading. See {Analyzer} and the `tests` command's refusal behaviour.
49
+ DEFAULT_GLOBAL_FILES = [
50
+ "Gemfile",
51
+ "Gemfile.lock",
52
+ "config/application.rb",
53
+ "config/environment.rb",
54
+ "config/boot.rb",
55
+ "config/environments/**",
56
+ "config/initializers/**",
57
+ "config/routes.rb",
58
+ "spec/spec_helper.rb",
59
+ "spec/rails_helper.rb",
60
+ "test/test_helper.rb"
61
+ ].freeze
62
+
63
+ RAILS_EXTRACTORS = %i[routes associations callbacks jobs mailers delegation views].freeze
64
+ TEST_FRAMEWORKS = %i[auto rspec minitest].freeze
65
+ KNOWN_SECTIONS = %w[version paths analysis rails tests cache].freeze
66
+
67
+ attr_reader :include_patterns, :view_patterns, :exclude_patterns,
68
+ :discover_engines, :min_confidence, :default_depth,
69
+ :include_low_confidence, :rails_features, :test_framework,
70
+ :unsafe_global_files, :cache_enabled, :cache_directory, :warnings, :source_path
71
+
72
+ # @param settings [Hash] raw settings, normally from YAML
73
+ # @param source_path [String, nil] where the settings came from, for messages
74
+ def initialize(settings = {}, source_path: nil)
75
+ @warnings = []
76
+ @source_path = source_path
77
+ settings = stringify(settings || {})
78
+
79
+ validate_sections!(settings)
80
+
81
+ paths = section(settings, "paths")
82
+ analysis = section(settings, "analysis")
83
+ rails = section(settings, "rails")
84
+ tests = section(settings, "tests")
85
+ cache = section(settings, "cache")
86
+
87
+ @include_patterns = string_list(paths, "include", DEFAULT_INCLUDE)
88
+ @view_patterns = string_list(paths, "views", DEFAULT_VIEW_INCLUDE)
89
+ @exclude_patterns = string_list(paths, "exclude", DEFAULT_EXCLUDE)
90
+ @discover_engines = boolean(paths, "discover_engines", true)
91
+
92
+ @min_confidence = confidence_value(analysis, "min_confidence", Confidence::MEDIUM)
93
+ @default_depth = depth_value(analysis["default_depth"])
94
+ @include_low_confidence = boolean(analysis, "include_low_confidence", false)
95
+
96
+ @rails_features = RAILS_EXTRACTORS.to_h { |name| [name, boolean(rails, name.to_s, true)] }.freeze
97
+
98
+ @test_framework = enum_value(tests, "framework", TEST_FRAMEWORKS, :auto)
99
+ @unsafe_global_files = string_list(tests, "unsafe_global_files", DEFAULT_GLOBAL_FILES)
100
+
101
+ @cache_enabled = boolean(cache, "enabled", true)
102
+ @cache_directory = (cache["directory"] || "tmp/ripple_effect").to_s
103
+
104
+ @warnings.freeze
105
+ end
106
+
107
+ # Loads `.ripple-effect.yml` from +root+, or an explicit +path+.
108
+ #
109
+ # @param root [String] project root
110
+ # @param path [String, nil] explicit config path; must exist when given
111
+ # @return [Configuration] defaults when no config file is present
112
+ # @raise [ConfigurationError] when the file is unreadable, malformed, or an
113
+ # explicitly requested path is missing
114
+ def self.load(root:, path: nil)
115
+ config_path = path ? File.expand_path(path, root) : File.join(root, CONFIG_FILENAME)
116
+
117
+ unless File.file?(config_path)
118
+ raise ConfigurationError, "config file not found: #{config_path}" if path
119
+
120
+ return new({}, source_path: nil)
121
+ end
122
+
123
+ new(parse(config_path), source_path: config_path)
124
+ end
125
+
126
+ # Parses a YAML config file safely: no aliases, no object deserialisation.
127
+ #
128
+ # @return [Hash]
129
+ def self.parse(config_path)
130
+ raw = YAML.safe_load_file(config_path, permitted_classes: [], aliases: false)
131
+ raise ConfigurationError, "config file #{config_path} must contain a YAML mapping" unless raw.is_a?(Hash)
132
+
133
+ version = raw["version"]
134
+ raise ConfigurationError, "config file #{config_path} must declare `version: #{SCHEMA_VERSION}`" if version.nil?
135
+
136
+ unless version == SCHEMA_VERSION
137
+ raise ConfigurationError,
138
+ "unsupported config version #{version.inspect} in #{config_path}, expected #{SCHEMA_VERSION}"
139
+ end
140
+
141
+ raw
142
+ rescue Psych::Exception => e
143
+ raise ConfigurationError, "could not parse #{config_path}: #{e.message}"
144
+ end
145
+
146
+ # @param feature [Symbol] one of {RAILS_EXTRACTORS}
147
+ # @return [Boolean]
148
+ def rails_feature?(feature)
149
+ @rails_features.fetch(feature, false)
150
+ end
151
+
152
+ # The effective confidence floor for traversal: low-confidence edges are
153
+ # excluded unless the user opts in.
154
+ #
155
+ # @return [Symbol]
156
+ def effective_min_confidence
157
+ return Confidence::LOW if include_low_confidence
158
+
159
+ min_confidence
160
+ end
161
+
162
+ # A digest of everything that affects analysis output, used as part of the
163
+ # cache key so a config change invalidates the cache.
164
+ #
165
+ # @return [String] hex SHA-256
166
+ def digest
167
+ Digest::SHA256.hexdigest(to_h.inspect)
168
+ end
169
+
170
+ # @return [Hash] the fully resolved settings
171
+ def to_h
172
+ {
173
+ "version" => SCHEMA_VERSION,
174
+ "paths" => {
175
+ "include" => include_patterns, "views" => view_patterns,
176
+ "exclude" => exclude_patterns, "discover_engines" => discover_engines
177
+ },
178
+ "analysis" => {
179
+ "min_confidence" => min_confidence.to_s,
180
+ "default_depth" => default_depth,
181
+ "include_low_confidence" => include_low_confidence
182
+ },
183
+ "rails" => rails_features.transform_keys(&:to_s),
184
+ "tests" => { "framework" => test_framework.to_s, "unsafe_global_files" => unsafe_global_files },
185
+ "cache" => { "enabled" => cache_enabled, "directory" => cache_directory }
186
+ }
187
+ end
188
+
189
+ private
190
+
191
+ def validate_sections!(settings)
192
+ unknown = settings.keys - KNOWN_SECTIONS
193
+ return if unknown.empty?
194
+
195
+ @warnings << "unknown configuration key(s): #{unknown.sort.join(', ')}"
196
+ end
197
+
198
+ def section(settings, name)
199
+ value = settings[name]
200
+ return {} if value.nil?
201
+ raise ConfigurationError, "`#{name}` must be a mapping" unless value.is_a?(Hash)
202
+
203
+ value
204
+ end
205
+
206
+ def string_list(source, key, default)
207
+ value = source[key]
208
+ return default.dup if value.nil?
209
+ raise ConfigurationError, "`#{key}` must be a list of strings" unless value.is_a?(Array)
210
+
211
+ value.map do |entry|
212
+ raise ConfigurationError, "`#{key}` must be a list of strings" unless entry.is_a?(String)
213
+
214
+ normalize_glob(entry)
215
+ end
216
+ end
217
+
218
+ # Strips a leading "./" so patterns compare cleanly against project-relative paths.
219
+ def normalize_glob(pattern)
220
+ pattern.strip.sub(%r{\A\./}, "")
221
+ end
222
+
223
+ def boolean(source, key, default)
224
+ value = source[key]
225
+ return default if value.nil?
226
+ return value if [true, false].include?(value)
227
+
228
+ raise ConfigurationError, "`#{key}` must be true or false, got #{value.inspect}"
229
+ end
230
+
231
+ def confidence_value(source, key, default)
232
+ value = source[key]
233
+ return default if value.nil?
234
+ unless Confidence.valid?(value)
235
+ raise ConfigurationError,
236
+ "`#{key}` must be one of #{Confidence::ALL.join(', ')}, got #{value.inspect}"
237
+ end
238
+
239
+ Confidence.cast(value)
240
+ end
241
+
242
+ def enum_value(source, key, allowed, default)
243
+ value = source[key]
244
+ return default if value.nil?
245
+
246
+ symbol = value.to_s.to_sym
247
+ return symbol if allowed.include?(symbol)
248
+
249
+ raise ConfigurationError, "`#{key}` must be one of #{allowed.join(', ')}, got #{value.inspect}"
250
+ end
251
+
252
+ # `all` and nil both mean unlimited depth; anything else must be a positive integer.
253
+ def depth_value(value)
254
+ return nil if value.nil? || value.to_s == "all"
255
+ return value if value.is_a?(Integer) && value.positive?
256
+
257
+ raise ConfigurationError, "`default_depth` must be a positive integer or \"all\", got #{value.inspect}"
258
+ end
259
+
260
+ def stringify(hash)
261
+ hash.to_h { |key, value| [key.to_s, value.is_a?(Hash) ? stringify(value) : value] }
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RippleEffect
4
+ # A non-fatal fact about the analysis: something we could not resolve, skipped,
5
+ # or want the user to know limits the answer.
6
+ #
7
+ # Diagnostics carry a stable +code+ so tooling can match on it rather than on
8
+ # message text, which is free to change.
9
+ class Diagnostic
10
+ SEVERITIES = %i[info warning error].freeze
11
+
12
+ # Stable diagnostic codes emitted by v0.1.
13
+ CODES = %w[
14
+ unresolved_polymorphic_association
15
+ unresolved_association_target
16
+ unresolved_delegate_target
17
+ unresolved_method_receiver
18
+ unresolved_route_controller
19
+ unparsed_file
20
+ unmapped_changed_lines
21
+ global_file_changed
22
+ deleted_file
23
+ renamed_file
24
+ cache_discarded
25
+ dynamic_dispatch
26
+ empty_index
27
+ index_note
28
+ ].freeze
29
+
30
+ attr_reader :severity, :code, :message, :path, :line
31
+
32
+ # @param code [String] one of {CODES}, or a new stable code
33
+ # @param message [String] human explanation
34
+ # @param severity [Symbol] one of {SEVERITIES}
35
+ # @param path [String, nil] project-relative path
36
+ # @param line [Integer, nil]
37
+ def initialize(code:, message:, severity: :warning, path: nil, line: nil)
38
+ @severity = self.class.cast_severity(severity)
39
+ @code = code.to_s.freeze
40
+ @message = message.to_s.freeze
41
+ @path = path&.to_s&.freeze
42
+ @line = line
43
+ freeze
44
+ end
45
+
46
+ # @return [Symbol]
47
+ # @raise [ArgumentError] on an unknown severity
48
+ def self.cast_severity(severity)
49
+ symbol = severity.to_s.to_sym
50
+ return symbol if SEVERITIES.include?(symbol)
51
+
52
+ raise ArgumentError, "unknown diagnostic severity #{severity.inspect}"
53
+ end
54
+
55
+ # @return [String] identity used to deduplicate repeated diagnostics
56
+ def key
57
+ "#{code}|#{path}|#{line}|#{message}"
58
+ end
59
+
60
+ # @return [Hash] JSON-compatible representation with deterministic key order
61
+ def to_h
62
+ {
63
+ "severity" => severity.to_s,
64
+ "code" => code,
65
+ "path" => path,
66
+ "line" => line,
67
+ "message" => message
68
+ }
69
+ end
70
+
71
+ # @return [String] "warning: message (path:line)"
72
+ def to_s
73
+ suffix = if path
74
+ " (#{line ? "#{path}:#{line}" : path})"
75
+ else
76
+ ""
77
+ end
78
+ "#{severity}: #{message}#{suffix}"
79
+ end
80
+
81
+ def ==(other)
82
+ other.is_a?(Diagnostic) && other.key == key
83
+ end
84
+ alias eql? ==
85
+
86
+ def hash
87
+ key.hash
88
+ end
89
+ end
90
+ end