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.
- checksums.yaml +7 -0
- data/.ripple-effect.yml.example +56 -0
- data/ARCHITECTURE.md +222 -0
- data/CHANGELOG.md +115 -0
- data/CODE_OF_CONDUCT.md +64 -0
- data/CONTRIBUTING.md +112 -0
- data/LICENSE.txt +21 -0
- data/README.md +305 -0
- data/SECURITY.md +73 -0
- data/docs/ANALYSIS_MODEL.md +275 -0
- data/docs/CLI.md +276 -0
- data/docs/CONFIGURATION.md +178 -0
- data/docs/DECISIONS.md +210 -0
- data/docs/PUBLIC_LAUNCH_CHECKLIST.md +105 -0
- data/docs/RELEASING.md +94 -0
- data/docs/TESTING.md +179 -0
- data/exe/ripple-effect +7 -0
- data/lib/ripple_effect/analyzer.rb +379 -0
- data/lib/ripple_effect/cache_store.rb +207 -0
- data/lib/ripple_effect/cli/application.rb +126 -0
- data/lib/ripple_effect/cli/command.rb +165 -0
- data/lib/ripple_effect/cli/diff_command.rb +76 -0
- data/lib/ripple_effect/cli/doctor_command.rb +106 -0
- data/lib/ripple_effect/cli/graph_command.rb +61 -0
- data/lib/ripple_effect/cli/inspect_command.rb +66 -0
- data/lib/ripple_effect/cli/tests_command.rb +109 -0
- data/lib/ripple_effect/cli/version_command.rb +46 -0
- data/lib/ripple_effect/confidence.rb +61 -0
- data/lib/ripple_effect/configuration.rb +264 -0
- data/lib/ripple_effect/diagnostic.rb +90 -0
- data/lib/ripple_effect/diff/changed_symbol_resolver.rb +292 -0
- data/lib/ripple_effect/diff/git.rb +175 -0
- data/lib/ripple_effect/diff/hunk.rb +80 -0
- data/lib/ripple_effect/edge.rb +114 -0
- data/lib/ripple_effect/error.rb +23 -0
- data/lib/ripple_effect/extractors/base.rb +292 -0
- data/lib/ripple_effect/extractors/rails_associations.rb +102 -0
- data/lib/ripple_effect/extractors/rails_callbacks.rb +144 -0
- data/lib/ripple_effect/extractors/rails_delegation.rb +121 -0
- data/lib/ripple_effect/extractors/rails_jobs.rb +131 -0
- data/lib/ripple_effect/extractors/rails_mailers.rb +120 -0
- data/lib/ripple_effect/extractors/rails_routes.rb +256 -0
- data/lib/ripple_effect/extractors/rails_views.rb +299 -0
- data/lib/ripple_effect/extractors/ruby_structure.rb +221 -0
- data/lib/ripple_effect/extractors/test_conventions.rb +135 -0
- data/lib/ripple_effect/formatters/dot.rb +69 -0
- data/lib/ripple_effect/formatters/json.rb +43 -0
- data/lib/ripple_effect/formatters/text.rb +197 -0
- data/lib/ripple_effect/graph.rb +199 -0
- data/lib/ripple_effect/node.rb +153 -0
- data/lib/ripple_effect/project.rb +264 -0
- data/lib/ripple_effect/result.rb +147 -0
- data/lib/ripple_effect/risk.rb +167 -0
- data/lib/ripple_effect/static_index/adapter.rb +84 -0
- data/lib/ripple_effect/static_index/rubydex_adapter.rb +356 -0
- data/lib/ripple_effect/traversal/impact_walker.rb +153 -0
- data/lib/ripple_effect/version.rb +11 -0
- data/lib/ripple_effect.rb +89 -0
- 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
|