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