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,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "command"
|
|
5
|
+
require_relative "inspect_command"
|
|
6
|
+
require_relative "diff_command"
|
|
7
|
+
require_relative "tests_command"
|
|
8
|
+
require_relative "graph_command"
|
|
9
|
+
require_relative "doctor_command"
|
|
10
|
+
require_relative "version_command"
|
|
11
|
+
|
|
12
|
+
module RippleEffect
|
|
13
|
+
module CLI
|
|
14
|
+
# Parses global flags, dispatches to a subcommand, and owns the process's
|
|
15
|
+
# exit code. All CLI parsing lives here and in {Command}; no domain object
|
|
16
|
+
# ever sees an ARGV.
|
|
17
|
+
class Application
|
|
18
|
+
COMMANDS = {
|
|
19
|
+
"inspect" => InspectCommand,
|
|
20
|
+
"diff" => DiffCommand,
|
|
21
|
+
"tests" => TestsCommand,
|
|
22
|
+
"graph" => GraphCommand,
|
|
23
|
+
"doctor" => DoctorCommand,
|
|
24
|
+
"version" => VersionCommand
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
BANNER = <<~BANNER
|
|
28
|
+
Usage: ripple-effect COMMAND [options]
|
|
29
|
+
|
|
30
|
+
Change-impact analysis for Ruby on Rails.
|
|
31
|
+
Ripple Effect is deterministic and local: it never boots your application,
|
|
32
|
+
connects to your database, or makes a network call.
|
|
33
|
+
|
|
34
|
+
Commands:
|
|
35
|
+
inspect SYMBOL What may be affected by changing SYMBOL, and why
|
|
36
|
+
diff BASE [HEAD] Blast radius of everything changed between revisions
|
|
37
|
+
tests BASE [HEAD] Test files most likely relevant to a change
|
|
38
|
+
graph SYMBOL Emit the impacted subgraph as DOT or JSON
|
|
39
|
+
doctor Check this project and report what Ripple Effect sees
|
|
40
|
+
version Print the version
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
ripple-effect inspect 'BillingService#charge'
|
|
44
|
+
ripple-effect diff main
|
|
45
|
+
bundle exec rspec $(ripple-effect tests main)
|
|
46
|
+
ripple-effect graph 'BillingService#charge' --format dot | dot -Tsvg -o impact.svg
|
|
47
|
+
|
|
48
|
+
Global options:
|
|
49
|
+
BANNER
|
|
50
|
+
|
|
51
|
+
def initialize(stdout: $stdout, stderr: $stderr)
|
|
52
|
+
@stdout = stdout
|
|
53
|
+
@stderr = stderr
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param argv [Array<String>]
|
|
57
|
+
# @return [Integer] the process exit code
|
|
58
|
+
def run(argv)
|
|
59
|
+
options = { format: "text", verbose: false, quiet: false, no_cache: false }
|
|
60
|
+
rest = parse_global(argv.dup, options)
|
|
61
|
+
|
|
62
|
+
return handle_no_command(options) if rest.empty?
|
|
63
|
+
|
|
64
|
+
name = rest.shift
|
|
65
|
+
command_class = COMMANDS[name]
|
|
66
|
+
|
|
67
|
+
return unknown_command(name) if command_class.nil?
|
|
68
|
+
|
|
69
|
+
command_class.new(options: options, stdout: @stdout, stderr: @stderr).call(rest)
|
|
70
|
+
rescue OptionParser::ParseError => e
|
|
71
|
+
@stderr.puts "Error: #{e.message}"
|
|
72
|
+
@stderr.puts "Run `ripple-effect --help` for usage."
|
|
73
|
+
Command::USER_ERROR
|
|
74
|
+
rescue Interrupt
|
|
75
|
+
@stderr.puts "Interrupted."
|
|
76
|
+
Command::INTERNAL_ERROR
|
|
77
|
+
rescue RippleEffect::Error => e
|
|
78
|
+
@stderr.puts "Error: #{e.message}"
|
|
79
|
+
Command::INTERNAL_ERROR
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Stops at the first non-flag argument so that a subcommand's own flags are
|
|
85
|
+
# left for the subcommand to parse.
|
|
86
|
+
def parse_global(argv, options)
|
|
87
|
+
parser = OptionParser.new do |opts|
|
|
88
|
+
opts.banner = BANNER
|
|
89
|
+
opts.on("--root PATH", "Project root (default: current directory)") { |v| options[:root] = v }
|
|
90
|
+
opts.on("--config PATH", "Path to a .ripple-effect.yml") { |v| options[:config] = v }
|
|
91
|
+
opts.on("--format FORMAT", %w[text json], "Output format: text (default) or json") do |v|
|
|
92
|
+
options[:format] = v
|
|
93
|
+
# `tests` defaults to `paths` rather than `text`, so it needs to know
|
|
94
|
+
# whether the user actually asked for a format or just got the default.
|
|
95
|
+
options[:format_given] = true
|
|
96
|
+
end
|
|
97
|
+
opts.on("--no-cache", "Ignore and do not write the graph cache") { options[:no_cache] = true }
|
|
98
|
+
opts.on("--verbose", "Include stats and every diagnostic") { options[:verbose] = true }
|
|
99
|
+
opts.on("--quiet", "Suppress non-essential output") { options[:quiet] = true }
|
|
100
|
+
opts.on("-v", "--version", "Print the version") { options[:print_version] = true }
|
|
101
|
+
opts.on("-h", "--help", "Show this message") { options[:print_help] = true }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
parser.order(argv)
|
|
105
|
+
ensure
|
|
106
|
+
@parser = parser
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def handle_no_command(options)
|
|
110
|
+
if options[:print_version]
|
|
111
|
+
@stdout.puts VERSION
|
|
112
|
+
return Command::SUCCESS
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
@stdout.puts @parser
|
|
116
|
+
options[:print_help] ? Command::SUCCESS : Command::USER_ERROR
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def unknown_command(name)
|
|
120
|
+
@stderr.puts "Error: unknown command `#{name}`"
|
|
121
|
+
@stderr.puts "Available commands: #{COMMANDS.keys.join(', ')}"
|
|
122
|
+
Command::USER_ERROR
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../project"
|
|
5
|
+
require_relative "../analyzer"
|
|
6
|
+
require_relative "../formatters/text"
|
|
7
|
+
require_relative "../formatters/json"
|
|
8
|
+
require_relative "../formatters/dot"
|
|
9
|
+
|
|
10
|
+
module RippleEffect
|
|
11
|
+
module CLI
|
|
12
|
+
# Shared behaviour for every subcommand: option parsing, project construction,
|
|
13
|
+
# and the exit-code contract.
|
|
14
|
+
#
|
|
15
|
+
# Exit codes are part of the interface:
|
|
16
|
+
# 0 success
|
|
17
|
+
# 1 a threshold the user asked us to enforce was met
|
|
18
|
+
# 2 a query, configuration, or usage error
|
|
19
|
+
# 3 an analyzer or internal failure
|
|
20
|
+
#
|
|
21
|
+
# @abstract Subclasses implement {#run} and {#banner}.
|
|
22
|
+
class Command
|
|
23
|
+
SUCCESS = 0
|
|
24
|
+
THRESHOLD_MET = 1
|
|
25
|
+
USER_ERROR = 2
|
|
26
|
+
INTERNAL_ERROR = 3
|
|
27
|
+
|
|
28
|
+
attr_reader :options, :stdout, :stderr
|
|
29
|
+
|
|
30
|
+
# @param options [Hash] global options already parsed by {Application}
|
|
31
|
+
def initialize(options: {}, stdout: $stdout, stderr: $stderr)
|
|
32
|
+
@options = options
|
|
33
|
+
@stdout = stdout
|
|
34
|
+
@stderr = stderr
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @param argv [Array<String>] arguments after the subcommand name
|
|
38
|
+
# @return [Integer] the process exit code
|
|
39
|
+
def call(argv)
|
|
40
|
+
# `--help` unwinds from inside OptionParser, which has no other way to say
|
|
41
|
+
# "we are done, successfully".
|
|
42
|
+
catch(:halt) do
|
|
43
|
+
rest = parse(argv)
|
|
44
|
+
run(rest)
|
|
45
|
+
end
|
|
46
|
+
rescue OptionParser::ParseError => e
|
|
47
|
+
report(e, code: USER_ERROR, error_code: "usage")
|
|
48
|
+
rescue QueryError, ConfigurationError, ProjectError, GitError => e
|
|
49
|
+
report(e, code: USER_ERROR, error_code: user_error_code(e))
|
|
50
|
+
rescue IndexError => e
|
|
51
|
+
report(e, code: INTERNAL_ERROR, error_code: "index_error")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [String] the usage banner
|
|
55
|
+
def banner = raise NotImplementedError
|
|
56
|
+
|
|
57
|
+
# @param arguments [Array<String>] positional arguments
|
|
58
|
+
# @return [Integer] exit code
|
|
59
|
+
def run(arguments) = raise NotImplementedError
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# Subclasses add their own flags here.
|
|
64
|
+
def define_options(parser); end
|
|
65
|
+
|
|
66
|
+
def parse(argv)
|
|
67
|
+
parser = OptionParser.new do |opts|
|
|
68
|
+
opts.banner = banner
|
|
69
|
+
define_options(opts)
|
|
70
|
+
define_global_options(opts)
|
|
71
|
+
opts.on("-h", "--help", "Show this message") do
|
|
72
|
+
stdout.puts opts
|
|
73
|
+
throw :halt, SUCCESS
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
parser.parse(argv)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The global flags are accepted after the subcommand as well as before it.
|
|
81
|
+
# `ripple-effect inspect Foo --root ../app` is what people actually type, and
|
|
82
|
+
# rejecting it would be a pointless lesson in argument order.
|
|
83
|
+
def define_global_options(parser)
|
|
84
|
+
parser.on("--root PATH", "Project root (default: current directory)") { |v| options[:root] = v }
|
|
85
|
+
parser.on("--config PATH", "Path to a .ripple-effect.yml") { |v| options[:config] = v }
|
|
86
|
+
parser.on("--no-cache", "Ignore and do not write the graph cache") { options[:no_cache] = true }
|
|
87
|
+
parser.on("--verbose", "Include stats and every diagnostic") { options[:verbose] = true }
|
|
88
|
+
parser.on("--quiet", "Suppress non-essential output") { options[:quiet] = true }
|
|
89
|
+
|
|
90
|
+
# `tests` and `graph` define their own --format with different values.
|
|
91
|
+
return if respond_to?(:custom_format_option?, true) && custom_format_option?
|
|
92
|
+
|
|
93
|
+
parser.on("--format FORMAT", %w[text json], "Output format: text (default) or json") do |value|
|
|
94
|
+
options[:format] = value
|
|
95
|
+
options[:format_given] = true
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Subclasses that define their own --format override this.
|
|
100
|
+
def custom_format_option? = false
|
|
101
|
+
|
|
102
|
+
def project
|
|
103
|
+
@project ||= Project.new(root: options[:root] || Dir.pwd, config_path: options[:config])
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def analyzer
|
|
107
|
+
@analyzer ||= Analyzer.new(project: project, cache: !options[:no_cache])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def json? = options[:format] == "json"
|
|
111
|
+
|
|
112
|
+
# Prints a result in whichever format the user asked for.
|
|
113
|
+
def emit(result)
|
|
114
|
+
stdout.print(
|
|
115
|
+
if json?
|
|
116
|
+
Formatters::Json.new(result: result).render
|
|
117
|
+
else
|
|
118
|
+
Formatters::Text.new(result: result, verbose: options[:verbose]).render
|
|
119
|
+
end
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# In JSON mode an error is still JSON, on stderr, so a consumer never has to
|
|
124
|
+
# parse a human sentence.
|
|
125
|
+
def report(error, code:, error_code:)
|
|
126
|
+
if json?
|
|
127
|
+
stderr.print Formatters::Json.error(error, code: error_code)
|
|
128
|
+
else
|
|
129
|
+
stderr.puts "Error: #{error.message}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
code
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def user_error_code(error)
|
|
136
|
+
case error
|
|
137
|
+
when QueryError then "query_error"
|
|
138
|
+
when ConfigurationError then "configuration_error"
|
|
139
|
+
when GitError then "git_error"
|
|
140
|
+
else "project_error"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# `--depth 3` / `--depth all`
|
|
145
|
+
def parse_depth(value)
|
|
146
|
+
return nil if value.nil? || value == "all"
|
|
147
|
+
|
|
148
|
+
depth = Integer(value, exception: false)
|
|
149
|
+
raise OptionParser::InvalidArgument, "--depth must be a positive integer or \"all\"" if depth.nil? || depth < 1
|
|
150
|
+
|
|
151
|
+
depth
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def parse_confidence(value)
|
|
155
|
+
return nil if value.nil?
|
|
156
|
+
unless Confidence.valid?(value)
|
|
157
|
+
raise OptionParser::InvalidArgument,
|
|
158
|
+
"--min-confidence must be high, medium, or low"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
Confidence.cast(value)
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
module CLI
|
|
7
|
+
# `ripple-effect diff BASE [HEAD]`
|
|
8
|
+
class DiffCommand < Command
|
|
9
|
+
def banner
|
|
10
|
+
<<~BANNER
|
|
11
|
+
Usage: ripple-effect diff BASE [HEAD] [options]
|
|
12
|
+
|
|
13
|
+
Show the blast radius of everything changed between two revisions.
|
|
14
|
+
With only BASE, compares BASE against the working tree, including both
|
|
15
|
+
staged and unstaged changes.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
ripple-effect diff main
|
|
19
|
+
ripple-effect diff origin/main --format json
|
|
20
|
+
ripple-effect diff v1.2.0 HEAD --fail-on-risk high
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
BANNER
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run(arguments)
|
|
27
|
+
base, head = arguments
|
|
28
|
+
|
|
29
|
+
if base.nil?
|
|
30
|
+
stderr.puts "Error: diff requires a BASE revision (e.g. `ripple-effect diff main`)"
|
|
31
|
+
return USER_ERROR
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
result = analyzer.diff(
|
|
35
|
+
base: base, head: head,
|
|
36
|
+
depth: @depth_given ? @depth : :default,
|
|
37
|
+
min_confidence: @min_confidence,
|
|
38
|
+
include_low_confidence: @include_low_confidence
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
emit(result)
|
|
42
|
+
exit_code_for(result)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# Analysis finding impact is not a failure. Exiting non-zero happens only
|
|
48
|
+
# when the user asked us to enforce a threshold.
|
|
49
|
+
def exit_code_for(result)
|
|
50
|
+
return SUCCESS unless @fail_on_risk
|
|
51
|
+
return SUCCESS unless result.risk.at_least?(@fail_on_risk)
|
|
52
|
+
|
|
53
|
+
stderr.puts "Risk #{result.risk.level} met the --fail-on-risk #{@fail_on_risk} threshold." unless json?
|
|
54
|
+
|
|
55
|
+
THRESHOLD_MET
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def define_options(parser)
|
|
59
|
+
parser.on("--depth N", "Maximum hops to follow (integer or \"all\")") do |value|
|
|
60
|
+
@depth = parse_depth(value)
|
|
61
|
+
@depth_given = true
|
|
62
|
+
end
|
|
63
|
+
parser.on("--min-confidence LEVEL", "Only follow edges at least this confident") do |value|
|
|
64
|
+
@min_confidence = parse_confidence(value)
|
|
65
|
+
end
|
|
66
|
+
parser.on("--include-low-confidence", "Also follow low-confidence edges") do
|
|
67
|
+
@include_low_confidence = true
|
|
68
|
+
end
|
|
69
|
+
parser.on("--fail-on-risk LEVEL", %w[low medium high critical],
|
|
70
|
+
"Exit 1 when risk reaches this level") do |value|
|
|
71
|
+
@fail_on_risk = value.to_sym
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
module CLI
|
|
7
|
+
# `ripple-effect doctor`
|
|
8
|
+
#
|
|
9
|
+
# Reports what RippleEffect can see and what it will do, including what it
|
|
10
|
+
# will never do. Makes no network call, here or anywhere else.
|
|
11
|
+
class DoctorCommand < Command
|
|
12
|
+
def banner
|
|
13
|
+
<<~BANNER
|
|
14
|
+
Usage: ripple-effect doctor [options]
|
|
15
|
+
|
|
16
|
+
Check that Ripple Effect can analyse this project, and report what it found.
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
BANNER
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run(_arguments)
|
|
23
|
+
report = analyzer.doctor
|
|
24
|
+
|
|
25
|
+
if json?
|
|
26
|
+
stdout.print "#{JSON.pretty_generate(report)}\n"
|
|
27
|
+
return SUCCESS
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
render(report)
|
|
31
|
+
report["ruby_supported"] ? SUCCESS : USER_ERROR
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def render(report)
|
|
37
|
+
stdout.puts "Ripple Effect #{VERSION}"
|
|
38
|
+
stdout.puts ""
|
|
39
|
+
|
|
40
|
+
section("Project", [
|
|
41
|
+
["root", report["root"]],
|
|
42
|
+
["looks like Rails", yes_no(report["rails_like"])],
|
|
43
|
+
["config", report["config_path"] || "(defaults; no .ripple-effect.yml)"],
|
|
44
|
+
["test framework", report["test_framework"]]
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
section("Environment", [
|
|
48
|
+
["ruby", ruby_line(report)],
|
|
49
|
+
["static index", report["backend"]],
|
|
50
|
+
["git repository", yes_no(report["git_repository"])]
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
section("Index", [
|
|
54
|
+
["files indexed", report["files_indexed"]],
|
|
55
|
+
["templates indexed", report["view_files"]],
|
|
56
|
+
["engines discovered", engines_line(report)],
|
|
57
|
+
["test files", report["test_files"]],
|
|
58
|
+
["graph nodes", report["nodes"]],
|
|
59
|
+
["graph edges", report["edges"]],
|
|
60
|
+
["diagnostics", report["diagnostics"]]
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
section("Cache", [
|
|
64
|
+
["enabled", yes_no(report["cache_enabled"])],
|
|
65
|
+
["directory", report["cache_directory"]],
|
|
66
|
+
["writable", yes_no(report["cache_writable"])]
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
section("Privacy", [
|
|
70
|
+
["network access", "never"],
|
|
71
|
+
["telemetry", "never"],
|
|
72
|
+
["boots your application", "never"],
|
|
73
|
+
["evaluates your source", "never"]
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
return if report["config_warnings"].empty?
|
|
77
|
+
|
|
78
|
+
stdout.puts "Configuration warnings"
|
|
79
|
+
report["config_warnings"].each { |warning| stdout.puts " #{warning}" }
|
|
80
|
+
stdout.puts ""
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def section(title, rows)
|
|
84
|
+
stdout.puts title
|
|
85
|
+
rows.each { |label, value| stdout.puts " #{label.to_s.ljust(24)} #{value}" }
|
|
86
|
+
stdout.puts ""
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ruby_line(report)
|
|
90
|
+
support = report["ruby_supported"] ? "(supported)" : "(unsupported, needs >= 3.2)"
|
|
91
|
+
"#{report['ruby_version']} #{support}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def engines_line(report)
|
|
95
|
+
engines = report["engine_roots"]
|
|
96
|
+
return "none" if engines.empty?
|
|
97
|
+
|
|
98
|
+
"#{engines.length} (#{engines.first(4).join(', ')}#{', ...' if engines.length > 4})"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def yes_no(value)
|
|
102
|
+
value ? "yes" : "no"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
module CLI
|
|
7
|
+
# `ripple-effect graph SYMBOL --format dot`
|
|
8
|
+
class GraphCommand < Command
|
|
9
|
+
def banner
|
|
10
|
+
<<~BANNER
|
|
11
|
+
Usage: ripple-effect graph SYMBOL [options]
|
|
12
|
+
|
|
13
|
+
Emit the impacted subgraph for SYMBOL.
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
ripple-effect graph 'BillingService#charge' --format dot | dot -Tsvg -o impact.svg
|
|
17
|
+
ripple-effect graph User --format json
|
|
18
|
+
|
|
19
|
+
Options:
|
|
20
|
+
BANNER
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(arguments)
|
|
24
|
+
symbol = arguments.first
|
|
25
|
+
|
|
26
|
+
if symbol.nil?
|
|
27
|
+
stderr.puts "Error: graph requires a SYMBOL (e.g. 'BillingService#charge')"
|
|
28
|
+
return USER_ERROR
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
result = analyzer.inspect_symbol(
|
|
32
|
+
symbol, depth: @depth_given ? @depth : :default, path: @path
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
stdout.print(
|
|
36
|
+
if options[:format] == "json"
|
|
37
|
+
Formatters::Json.new(result: result).render
|
|
38
|
+
else
|
|
39
|
+
Formatters::Dot.new(result: result).render
|
|
40
|
+
end
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
SUCCESS
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# This command defines its own --format with a different set of values.
|
|
49
|
+
def custom_format_option? = true
|
|
50
|
+
|
|
51
|
+
def define_options(parser)
|
|
52
|
+
parser.on("--format FORMAT", %w[dot json], "dot (default) or json") { |value| options[:format] = value }
|
|
53
|
+
parser.on("--depth N", "Maximum hops to follow") do |value|
|
|
54
|
+
@depth = parse_depth(value)
|
|
55
|
+
@depth_given = true
|
|
56
|
+
end
|
|
57
|
+
parser.on("--path PATH", "Disambiguate a symbol declared in several files") { |value| @path = value }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "command"
|
|
4
|
+
|
|
5
|
+
module RippleEffect
|
|
6
|
+
module CLI
|
|
7
|
+
# `ripple-effect inspect SYMBOL`
|
|
8
|
+
class InspectCommand < Command
|
|
9
|
+
def banner
|
|
10
|
+
<<~BANNER
|
|
11
|
+
Usage: ripple-effect inspect SYMBOL [options]
|
|
12
|
+
|
|
13
|
+
Show what may be affected by changing SYMBOL, and why.
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
ripple-effect inspect 'BillingService#charge'
|
|
17
|
+
ripple-effect inspect User.find --depth 2
|
|
18
|
+
ripple-effect inspect app/models/order.rb --format json
|
|
19
|
+
|
|
20
|
+
Options:
|
|
21
|
+
BANNER
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run(arguments)
|
|
25
|
+
symbol = arguments.first
|
|
26
|
+
|
|
27
|
+
if symbol.nil?
|
|
28
|
+
stderr.puts "Error: inspect requires a SYMBOL (e.g. 'BillingService#charge')"
|
|
29
|
+
return USER_ERROR
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
result = analyzer.inspect_symbol(
|
|
33
|
+
symbol,
|
|
34
|
+
depth: @depth_given ? @depth : :default,
|
|
35
|
+
path: @path,
|
|
36
|
+
min_confidence: @min_confidence,
|
|
37
|
+
include_low_confidence: @include_low_confidence,
|
|
38
|
+
direction: @direction || :dependents
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
emit(result)
|
|
42
|
+
SUCCESS
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def define_options(parser)
|
|
48
|
+
parser.on("--depth N", "Maximum hops to follow (integer or \"all\")") do |value|
|
|
49
|
+
@depth = parse_depth(value)
|
|
50
|
+
@depth_given = true
|
|
51
|
+
end
|
|
52
|
+
parser.on("--path PATH", "Disambiguate a symbol declared in several files") { |value| @path = value }
|
|
53
|
+
parser.on("--min-confidence LEVEL", "Only follow edges at least this confident") do |value|
|
|
54
|
+
@min_confidence = parse_confidence(value)
|
|
55
|
+
end
|
|
56
|
+
parser.on("--include-low-confidence", "Also follow low-confidence edges") do
|
|
57
|
+
@include_low_confidence = true
|
|
58
|
+
end
|
|
59
|
+
parser.on("--direction DIR", %w[dependents dependencies both],
|
|
60
|
+
"dependents (default), dependencies, or both") do |value|
|
|
61
|
+
@direction = value.to_sym
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|