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,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "graph"
4
+ require_relative "result"
5
+ require_relative "risk"
6
+ require_relative "cache_store"
7
+ require_relative "static_index/rubydex_adapter"
8
+ require_relative "traversal/impact_walker"
9
+ require_relative "diff/git"
10
+ require_relative "diff/changed_symbol_resolver"
11
+ require_relative "extractors/base"
12
+ require_relative "extractors/ruby_structure"
13
+ require_relative "extractors/rails_associations"
14
+ require_relative "extractors/rails_callbacks"
15
+ require_relative "extractors/rails_delegation"
16
+ require_relative "extractors/rails_jobs"
17
+ require_relative "extractors/rails_mailers"
18
+ require_relative "extractors/rails_routes"
19
+ require_relative "extractors/rails_views"
20
+ require_relative "extractors/test_conventions"
21
+
22
+ module RippleEffect
23
+ # The public entry point: builds the graph once, then answers questions about it.
24
+ #
25
+ # @example
26
+ # analyzer = RippleEffect::Analyzer.new(project: RippleEffect::Project.new(root: Dir.pwd))
27
+ # result = analyzer.inspect_symbol("BillingService#charge")
28
+ # result.risk.level # => :high
29
+ class Analyzer
30
+ # Order matters. The structure extractor creates the nodes everything else
31
+ # attaches to, associations must precede delegation (which resolves targets
32
+ # through them), and test conventions run last so they can see every node.
33
+ EXTRACTORS = [
34
+ Extractors::RubyStructure,
35
+ Extractors::RailsAssociations,
36
+ Extractors::RailsCallbacks,
37
+ Extractors::RailsJobs,
38
+ Extractors::RailsMailers,
39
+ Extractors::RailsRoutes,
40
+ Extractors::RailsDelegation,
41
+ Extractors::RailsViews,
42
+ Extractors::TestConventions
43
+ ].freeze
44
+
45
+ attr_reader :project, :diagnostics
46
+
47
+ # @param project [Project]
48
+ # @param cache [Boolean] read and write the on-disk graph cache
49
+ # @param index [StaticIndex::Adapter, nil] injected in tests
50
+ def initialize(project:, cache: true, index: nil)
51
+ @project = project
52
+ @cache_enabled = cache && project.configuration.cache_enabled
53
+ @index = index
54
+ @diagnostics = []
55
+ @cache_status = "disabled"
56
+ end
57
+
58
+ # Builds (or loads) the graph. Idempotent.
59
+ #
60
+ # @return [Graph]
61
+ def graph
62
+ @graph ||= build_graph
63
+ end
64
+
65
+ # @return [String] "hit", "miss", or "disabled"
66
+ def cache_status
67
+ graph
68
+ @cache_status
69
+ end
70
+
71
+ # Analyses the blast radius of one symbol.
72
+ #
73
+ # @param query [String] e.g. "BillingService#charge", "User", "app/models/user.rb"
74
+ # @param depth [Integer, nil] maximum hops, nil for unlimited
75
+ # @param path [String, nil] disambiguates a symbol declared in several files
76
+ # @param min_confidence [Symbol, nil] overrides the configured floor
77
+ # @param include_low_confidence [Boolean]
78
+ # @param direction [Symbol] :dependents, :dependencies, or :both
79
+ # @return [Result]
80
+ # @raise [QueryError] when the symbol is unknown or ambiguous
81
+ def inspect_symbol(query, depth: :default, path: nil, min_confidence: nil,
82
+ include_low_confidence: false, direction: :dependents)
83
+ nodes = resolve_query(query, path: path)
84
+
85
+ impacts = walk(
86
+ nodes.map(&:id),
87
+ depth: depth, min_confidence: min_confidence,
88
+ include_low_confidence: include_low_confidence, direction: direction
89
+ )
90
+
91
+ build_result(
92
+ query_type: :symbol, query_value: query,
93
+ changed_nodes: nodes, impacts: impacts, global_files: []
94
+ )
95
+ end
96
+
97
+ # Analyses everything changed between two Git revisions.
98
+ #
99
+ # @param base [String] a Git ref
100
+ # @param head [String, nil] a Git ref, or nil to compare against the working tree
101
+ # @return [Result]
102
+ # @raise [GitError] when the repository or refs are unusable
103
+ def diff(base:, head: nil, depth: :default, min_confidence: nil,
104
+ include_low_confidence: false)
105
+ git = Diff::Git.new(root: project.root)
106
+ changes = git.changed_files(base: base, head: head)
107
+
108
+ resolver = Diff::ChangedSymbolResolver.new(project: project, index: index, graph: graph, git: git)
109
+ changed_nodes = resolver.resolve(changes: changes, base: base, head: head)
110
+ @diagnostics.concat(resolver.diagnostics)
111
+
112
+ impacts = walk(
113
+ changed_nodes.map(&:id), depth: depth, min_confidence: min_confidence,
114
+ include_low_confidence: include_low_confidence, direction: :dependents
115
+ )
116
+
117
+ global_files = resolver.diagnostics.select { |d| d.code == "global_file_changed" }.map(&:path).compact
118
+
119
+ build_result(
120
+ query_type: :diff,
121
+ query_value: head ? "#{base}..#{head}" : base,
122
+ changed_nodes: changed_nodes, impacts: impacts, global_files: global_files
123
+ )
124
+ end
125
+
126
+ # Health and environment facts for the `doctor` command.
127
+ #
128
+ # @return [Hash]
129
+ def doctor
130
+ cache = cache_store
131
+ built = graph
132
+
133
+ {
134
+ "root" => project.root,
135
+ "rails_like" => project.rails_like?,
136
+ "ruby_version" => RUBY_VERSION,
137
+ "ruby_supported" => ruby_supported?,
138
+ "backend" => StaticIndex::RubydexAdapter.backend_version,
139
+ "git_repository" => Diff::Git.new(root: project.root).repository?,
140
+ "config_path" => project.configuration.source_path,
141
+ "config_warnings" => project.configuration.warnings,
142
+ "test_framework" => project.test_framework.to_s,
143
+ "engine_roots" => project.engine_roots,
144
+ "view_files" => project.view_paths.length,
145
+ "files_indexed" => project.source_paths.length,
146
+ "test_files" => project.test_paths.length,
147
+ "nodes" => built.node_count,
148
+ "edges" => built.edge_count,
149
+ "diagnostics" => @diagnostics.length,
150
+ "cache_enabled" => cache.enabled?,
151
+ "cache_directory" => cache.directory,
152
+ "cache_writable" => cache.writable?,
153
+ "network_access" => false,
154
+ "boots_application" => false
155
+ }
156
+ end
157
+
158
+ # Resolves a user query to the node(s) it names.
159
+ #
160
+ # @return [Array<Node>]
161
+ # @raise [QueryError] when nothing matches, or several things do
162
+ def resolve_query(query, path: nil)
163
+ text = query.to_s.strip
164
+ raise QueryError, "empty query" if text.empty?
165
+
166
+ # A path query addresses a whole file and its declarations.
167
+ if text.end_with?(".rb")
168
+ node = graph.node("file:#{text}")
169
+ raise QueryError, "no indexed file matches `#{text}`" unless node
170
+
171
+ return [node]
172
+ end
173
+
174
+ matches = graph.find_symbol(text)
175
+ matches = matches.select { |node| node.path == path } if path
176
+
177
+ raise QueryError, unknown_symbol_message(text) if matches.empty?
178
+ return matches if matches.length == 1
179
+
180
+ raise QueryError, ambiguous_message(text, matches)
181
+ end
182
+
183
+ private
184
+
185
+ def index
186
+ @index ||= StaticIndex::RubydexAdapter.new(project: project).index(paths: project.source_files)
187
+ end
188
+
189
+ def cache_store
190
+ @cache_store ||= CacheStore.new(
191
+ project: project,
192
+ backend_version: StaticIndex::RubydexAdapter.backend_version,
193
+ enabled: @cache_enabled
194
+ )
195
+ end
196
+
197
+ def build_graph
198
+ store = cache_store
199
+
200
+ if store.enabled?
201
+ cached = store.read
202
+ @diagnostics.concat(store.diagnostics)
203
+
204
+ if cached
205
+ @cache_status = "hit"
206
+ # Without this, a warm run reports no limitations at all and therefore
207
+ # looks more confident than the cold run that produced it.
208
+ @diagnostics.concat(store.restored_diagnostics)
209
+ return cached
210
+ end
211
+
212
+ @cache_status = "miss"
213
+ end
214
+
215
+ built = build_graph_from_source
216
+ store.write(built, @diagnostics) if store.enabled?
217
+ built
218
+ end
219
+
220
+ def build_graph_from_source
221
+ graph = Graph.new
222
+ sources = Extractors::SourceCache.new(project: project)
223
+ context = Extractors::Context.new(
224
+ project: project, index: index, graph: graph, sources: sources, diagnostics: @diagnostics
225
+ )
226
+
227
+ EXTRACTORS.each do |extractor_class|
228
+ feature = extractor_class.feature
229
+ next if feature && !project.configuration.rails_feature?(feature)
230
+
231
+ extractor_class.new(context).extract
232
+ end
233
+
234
+ @diagnostics.concat(index.diagnostics)
235
+ @diagnostics.concat(sources.diagnostics)
236
+ warn_if_index_is_empty
237
+ graph
238
+ end
239
+
240
+ # Indexing many Ruby files and finding no declarations means something
241
+ # upstream failed silently. Reporting "no impact" there would be wrong, so
242
+ # this is raised as an error instead.
243
+ def warn_if_index_is_empty
244
+ return if project.source_paths.empty?
245
+ return unless index.declarations.empty?
246
+
247
+ @diagnostics << Diagnostic.new(
248
+ code: "empty_index",
249
+ severity: :error,
250
+ message: "indexed #{project.source_paths.length} file(s) but found no Ruby declarations. " \
251
+ "Results are not trustworthy. Check that --root points at the project root, " \
252
+ "and that `paths.include` matches your layout. An engine monorepo keeps its " \
253
+ "code under directories like core/app or engines/*/app."
254
+ )
255
+ end
256
+
257
+ def walk(ids, depth:, min_confidence:, include_low_confidence:, direction:)
258
+ floor = min_confidence || effective_min_confidence(include_low_confidence)
259
+ resolved_depth = depth == :default ? project.configuration.default_depth : depth
260
+
261
+ walker = Traversal::ImpactWalker.new(graph: graph, min_confidence: floor)
262
+
263
+ case direction
264
+ when :both
265
+ forward = walker.walk(ids, depth: resolved_depth, direction: :dependencies)
266
+ reverse = walker.walk(ids, depth: resolved_depth, direction: :dependents)
267
+ merge_impacts(reverse, forward)
268
+ when :dependencies
269
+ walker.walk(ids, depth: resolved_depth, direction: :dependencies)
270
+ else
271
+ walker.walk(ids, depth: resolved_depth, direction: :dependents)
272
+ end
273
+ end
274
+
275
+ def merge_impacts(primary, secondary)
276
+ seen = primary.to_h { |impact| [impact.node.id, true] }
277
+ (primary + secondary.reject { |impact| seen[impact.node.id] })
278
+ .sort_by { |impact| [impact.depth, impact.node.id] }
279
+ end
280
+
281
+ def effective_min_confidence(include_low_confidence)
282
+ return Confidence::LOW if include_low_confidence
283
+
284
+ project.configuration.min_confidence
285
+ end
286
+
287
+ def build_result(query_type:, query_value:, changed_nodes:, impacts:, global_files:)
288
+ Result.new(
289
+ query_type: query_type,
290
+ query_value: query_value,
291
+ changed_nodes: changed_nodes,
292
+ impacts: impacts,
293
+ tests: rank_tests(impacts, changed_nodes),
294
+ risk: Risk.calculate(impacts: impacts, changed_nodes: changed_nodes, global_files: global_files),
295
+ diagnostics: @diagnostics.dup,
296
+ stats: {
297
+ # These four keys are part of the documented JSON contract. Project
298
+ # facts such as discovered engines belong to `doctor`, not here.
299
+ "files_indexed" => project.source_paths.length,
300
+ "nodes" => graph.node_count,
301
+ "edges" => graph.edge_count,
302
+ "cache" => @cache_status
303
+ }
304
+ )
305
+ end
306
+
307
+ # Test files reached by the impact walk, ranked by *why* they were reached.
308
+ #
309
+ # A spec that references the changed code outranks one that merely lives at the
310
+ # conventional path, and a directly-reached spec outranks a distant one.
311
+ def rank_tests(impacts, changed_nodes)
312
+ suggestions = {}
313
+
314
+ changed_paths = changed_nodes.map(&:path).uniq
315
+ impacts.each do |impact|
316
+ next unless impact.node.kind == :test_file
317
+
318
+ reason = test_reason(impact)
319
+ existing = suggestions[impact.node.path]
320
+ suggestion = TestSuggestion.new(
321
+ path: impact.node.path, reason: reason, confidence: impact.confidence,
322
+ depth: impact.depth, evidence: impact.reason&.evidence.to_s
323
+ )
324
+
325
+ suggestions[impact.node.path] = suggestion if existing.nil? || better?(suggestion, existing)
326
+ end
327
+
328
+ # A changed test file is itself worth running.
329
+ changed_paths.select { |path| project.test_path?(path) }.each do |path|
330
+ suggestions[path] = TestSuggestion.new(
331
+ path: path, reason: "reference", confidence: Confidence::HIGH, depth: 0, evidence: "diff.changed_test"
332
+ )
333
+ end
334
+
335
+ suggestions.values.sort_by { |suggestion| [suggestion.rank, suggestion.depth, suggestion.path] }
336
+ end
337
+
338
+ def test_reason(impact)
339
+ edge = impact.reason
340
+ return "heuristic" unless edge
341
+
342
+ case edge.evidence
343
+ when "convention.rspec_path", "convention.minitest_path" then "convention"
344
+ when "convention.request_spec_path" then "heuristic"
345
+ when /\Arails\./ then "rails_semantic"
346
+ else "reference"
347
+ end
348
+ end
349
+
350
+ def better?(candidate, existing)
351
+ ([candidate.rank, candidate.depth] <=> [existing.rank, existing.depth]).negative?
352
+ end
353
+
354
+ def ruby_supported?
355
+ Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("3.2")
356
+ end
357
+
358
+ def unknown_symbol_message(text)
359
+ suggestions = similar_symbols(text)
360
+ base = "no indexed symbol matches `#{text}`"
361
+ suggestions.empty? ? base : "#{base}\nDid you mean: #{suggestions.join(', ')}?"
362
+ end
363
+
364
+ def similar_symbols(text)
365
+ needle = text.downcase
366
+ graph.nodes
367
+ .select { |node| node.qualified_name&.downcase&.include?(needle.split(/[#.]/).first.to_s) }
368
+ .map(&:qualified_name)
369
+ .uniq
370
+ .sort
371
+ .first(5)
372
+ end
373
+
374
+ def ambiguous_message(text, matches)
375
+ lines = matches.each_with_index.map { |node, i| " #{i + 1}. #{node.location}" }
376
+ "`#{text}` matched #{matches.length} declarations\n#{lines.join("\n")}\nUse --path to disambiguate."
377
+ end
378
+ end
379
+ end
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+ require_relative "version"
7
+ require_relative "node"
8
+ require_relative "edge"
9
+ require_relative "diagnostic"
10
+ require_relative "graph"
11
+
12
+ module RippleEffect
13
+ # Persists a built graph as JSON so a warm run does not re-index the project.
14
+ #
15
+ # JSON, never Marshal: a cache file is ordinary data on disk that any process can
16
+ # write, and Marshal would turn a corrupted cache into arbitrary object
17
+ # construction. A cache that cannot be read is discarded, never fatal.
18
+ class CacheStore
19
+ MANIFEST_FILENAME = "manifest.json"
20
+ GRAPH_FILENAME = "graph.json"
21
+
22
+ attr_reader :directory, :diagnostics
23
+
24
+ # @param project [Project]
25
+ # @param backend_version [String] e.g. "rubydex 0.4.1"
26
+ # @param enabled [Boolean]
27
+ def initialize(project:, backend_version:, enabled: true)
28
+ @project = project
29
+ @backend_version = backend_version
30
+ @enabled = enabled
31
+ @directory = project.cache_directory
32
+ @diagnostics = []
33
+ @restored_diagnostics = []
34
+ end
35
+
36
+ # @return [Boolean]
37
+ def enabled? = @enabled
38
+
39
+ # Diagnostics restored alongside the graph on a cache hit.
40
+ #
41
+ # @return [Array<Diagnostic>]
42
+ attr_reader :restored_diagnostics
43
+
44
+ # Loads a cached graph, if one exists and is still valid for this project state.
45
+ #
46
+ # @return [Graph, nil]
47
+ def read
48
+ return nil unless enabled?
49
+
50
+ manifest = read_manifest
51
+ return nil if manifest.nil?
52
+ return nil unless manifest["key"] == cache_key
53
+
54
+ payload = read_json(File.join(directory, GRAPH_FILENAME))
55
+ return nil if payload.nil?
56
+
57
+ deserialize(payload)
58
+ rescue StandardError => e
59
+ discard("cache could not be read: #{e.message}")
60
+ nil
61
+ end
62
+
63
+ # @param graph [Graph]
64
+ # @param diagnostics [Array<Diagnostic>] stored with the graph, so that a warm
65
+ # run reports exactly the same limitations as the cold run that built it
66
+ # @return [Boolean] true when the cache was written
67
+ def write(graph, diagnostics = [])
68
+ return false unless enabled?
69
+
70
+ FileUtils.mkdir_p(directory)
71
+ File.write(File.join(directory, MANIFEST_FILENAME), JSON.pretty_generate(manifest_payload))
72
+ File.write(File.join(directory, GRAPH_FILENAME), JSON.generate(serialize(graph, diagnostics)))
73
+ true
74
+ rescue SystemCallError => e
75
+ discard("cache could not be written: #{e.message}")
76
+ false
77
+ end
78
+
79
+ # Whether the cache could be written, without actually creating anything.
80
+ #
81
+ # `doctor` asks this. It should not create a directory in the project as a
82
+ # side effect, least of all when run with `--no-cache`, so check the nearest
83
+ # existing ancestor instead.
84
+ #
85
+ # @return [Boolean]
86
+ def writable?
87
+ candidate = directory
88
+ candidate = File.dirname(candidate) while !File.exist?(candidate) && File.dirname(candidate) != candidate
89
+
90
+ File.directory?(candidate) && File.writable?(candidate)
91
+ rescue SystemCallError
92
+ false
93
+ end
94
+
95
+ # Removes any cached data.
96
+ # @return [void]
97
+ def clear
98
+ FileUtils.rm_rf(directory)
99
+ rescue SystemCallError
100
+ nil
101
+ end
102
+
103
+ # Everything that, if changed, must invalidate the cache: our version, the
104
+ # cache layout, the indexer, the Ruby running it, the configuration, and the
105
+ # exact content of every indexed file.
106
+ #
107
+ # @return [String] hex SHA-256
108
+ def cache_key
109
+ @cache_key ||= begin
110
+ digest = Digest::SHA256.new
111
+ digest << VERSION << "\0"
112
+ digest << CACHE_SCHEMA_VERSION.to_s << "\0"
113
+ digest << @backend_version << "\0"
114
+ digest << RUBY_VERSION << "\0"
115
+ digest << @project.configuration.digest << "\0"
116
+
117
+ @project.source_files.each do |path|
118
+ digest << @project.relative_path(path) << "\0"
119
+ digest << file_digest(path) << "\0"
120
+ end
121
+
122
+ digest.hexdigest
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def file_digest(path)
129
+ Digest::SHA256.file(path).hexdigest
130
+ rescue SystemCallError
131
+ "unreadable"
132
+ end
133
+
134
+ def manifest_payload
135
+ {
136
+ "key" => cache_key,
137
+ "cache_schema_version" => CACHE_SCHEMA_VERSION,
138
+ "tool_version" => VERSION,
139
+ "backend" => @backend_version,
140
+ "ruby_version" => RUBY_VERSION,
141
+ "files" => @project.source_paths.length
142
+ }
143
+ end
144
+
145
+ def read_manifest
146
+ read_json(File.join(directory, MANIFEST_FILENAME))
147
+ end
148
+
149
+ def read_json(path)
150
+ return nil unless File.file?(path)
151
+
152
+ JSON.parse(File.read(path))
153
+ rescue JSON::ParserError => e
154
+ discard("cache file #{File.basename(path)} is corrupt: #{e.message}")
155
+ nil
156
+ rescue SystemCallError
157
+ nil
158
+ end
159
+
160
+ def discard(message)
161
+ @diagnostics << Diagnostic.new(code: "cache_discarded", severity: :info, message: message)
162
+ clear
163
+ end
164
+
165
+ def serialize(graph, diagnostics)
166
+ {
167
+ "nodes" => graph.nodes.map(&:to_h),
168
+ "edges" => graph.edges.map(&:to_h),
169
+ "diagnostics" => diagnostics.map(&:to_h)
170
+ }
171
+ end
172
+
173
+ def deserialize(payload)
174
+ graph = Graph.new
175
+
176
+ Array(payload["nodes"]).each do |raw|
177
+ graph.add_node(
178
+ Node.new(
179
+ id: raw["id"], kind: raw["kind"], name: raw["name"],
180
+ qualified_name: raw["qualified_name"], path: raw["path"],
181
+ start_line: raw["start_line"], end_line: raw["end_line"],
182
+ metadata: raw["metadata"] || {}
183
+ )
184
+ )
185
+ end
186
+
187
+ @restored_diagnostics = Array(payload["diagnostics"]).map do |raw|
188
+ Diagnostic.new(
189
+ code: raw["code"], message: raw["message"], severity: raw["severity"] || :info,
190
+ path: raw["path"], line: raw["line"]
191
+ )
192
+ end
193
+
194
+ Array(payload["edges"]).each do |raw|
195
+ graph.add_edge(
196
+ Edge.new(
197
+ from_id: raw["from_id"], into_id: raw["into_id"], type: raw["type"],
198
+ evidence: raw["evidence"], confidence: raw["confidence"],
199
+ location: raw["location"], metadata: raw["metadata"] || {}
200
+ )
201
+ )
202
+ end
203
+
204
+ graph
205
+ end
206
+ end
207
+ end