fastererer 0.12.0 → 1.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 +4 -4
- data/CHANGELOG.md +75 -2
- data/README.md +97 -8
- data/config/locales/en.yml +60 -0
- data/lib/fastererer/analyzer.rb +28 -53
- data/lib/fastererer/cli.rb +21 -4
- data/lib/fastererer/explanation.rb +44 -0
- data/lib/fastererer/file_traverser.rb +43 -112
- data/lib/fastererer/finding.rb +18 -0
- data/lib/fastererer/formatters/base.rb +32 -0
- data/lib/fastererer/formatters/json_formatter.rb +48 -0
- data/lib/fastererer/formatters/rdjsonl_formatter.rb +34 -0
- data/lib/fastererer/formatters/text_formatter.rb +63 -0
- data/lib/fastererer/formatters/text_statistics.rb +45 -0
- data/lib/fastererer/formatters.rb +24 -0
- data/lib/fastererer/method_call.rb +139 -75
- data/lib/fastererer/method_definition.rb +61 -48
- data/lib/fastererer/offense.rb +5 -63
- data/lib/fastererer/offense_collector.rb +1 -1
- data/lib/fastererer/painter.rb +2 -1
- data/lib/fastererer/parser.rb +8 -4
- data/lib/fastererer/receiver.rb +14 -0
- data/lib/fastererer/report.rb +10 -0
- data/lib/fastererer/rescue_call.rb +10 -12
- data/lib/fastererer/rule_catalog.rb +83 -0
- data/lib/fastererer/rule_name.rb +17 -0
- data/lib/fastererer/scanners/for_loop_scanner.rb +23 -0
- data/lib/fastererer/scanners/method_call_scanner.rb +13 -17
- data/lib/fastererer/scanners/method_definition_scanner.rb +57 -29
- data/lib/fastererer/scanners/offensive.rb +2 -6
- data/lib/fastererer/scanners/rescue_call_scanner.rb +1 -1
- data/lib/fastererer/scanners/symbol_to_proc_check.rb +21 -8
- data/lib/fastererer/version.rb +1 -1
- metadata +19 -11
- data/.fastererer.yml +0 -27
- data/.ruby-version +0 -1
- data/CODE_OF_CONDUCT.md +0 -18
- data/CONTRIBUTING.md +0 -63
- data/Rakefile +0 -12
- data/SECURITY.md +0 -20
|
@@ -5,7 +5,9 @@ require 'English'
|
|
|
5
5
|
|
|
6
6
|
require_relative 'analyzer'
|
|
7
7
|
require_relative 'config'
|
|
8
|
-
require_relative '
|
|
8
|
+
require_relative 'finding'
|
|
9
|
+
require_relative 'report'
|
|
10
|
+
require_relative 'formatters/text_formatter'
|
|
9
11
|
|
|
10
12
|
module Fastererer
|
|
11
13
|
class FileTraverser
|
|
@@ -14,19 +16,17 @@ module Fastererer
|
|
|
14
16
|
EXCLUDE_PATHS_KEY = Config::EXCLUDE_PATHS_KEY
|
|
15
17
|
|
|
16
18
|
attr_reader :config, :parse_error_paths
|
|
17
|
-
attr_accessor :offenses_total_count
|
|
18
19
|
|
|
19
|
-
def initialize(path)
|
|
20
|
+
def initialize(path, formatter: Formatters::TextFormatter.new)
|
|
20
21
|
@path = Pathname(path || '.')
|
|
21
22
|
@parse_error_paths = []
|
|
22
23
|
@config = Config.new
|
|
23
|
-
@
|
|
24
|
+
@formatter = formatter
|
|
25
|
+
@findings = []
|
|
24
26
|
end
|
|
25
27
|
|
|
26
28
|
def traverse
|
|
27
|
-
|
|
28
|
-
output_parse_errors
|
|
29
|
-
output_statistics
|
|
29
|
+
@formatter.render(build_report)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def config_file
|
|
@@ -34,7 +34,13 @@ module Fastererer
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def offenses_found?
|
|
37
|
-
|
|
37
|
+
findings.any?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def path_missing?
|
|
41
|
+
return @path_missing if defined?(@path_missing)
|
|
42
|
+
|
|
43
|
+
@path_missing = !@path.exist?
|
|
38
44
|
end
|
|
39
45
|
|
|
40
46
|
def scannable_files
|
|
@@ -43,79 +49,53 @@ module Fastererer
|
|
|
43
49
|
|
|
44
50
|
private
|
|
45
51
|
|
|
46
|
-
|
|
52
|
+
attr_reader :findings
|
|
47
53
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
def build_report
|
|
55
|
+
scan_files
|
|
56
|
+
|
|
57
|
+
Report.new(
|
|
58
|
+
findings:,
|
|
59
|
+
files_inspected_count: scannable_files.count,
|
|
60
|
+
unparsable_files: parse_error_paths,
|
|
61
|
+
missing_path: (@path.to_s if path_missing?)
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def scan_files
|
|
66
|
+
scannable_files.each { |ruby_file| scan_file(ruby_file) }
|
|
54
67
|
end
|
|
55
68
|
|
|
56
69
|
def scan_file(path)
|
|
57
70
|
analyzer = Analyzer.new(path)
|
|
58
71
|
analyzer.scan
|
|
59
|
-
rescue
|
|
60
|
-
parse_error_paths.push(ErrorData.new(path, e.class, e.message)
|
|
72
|
+
rescue Fastererer::ParseError, SystemCallError, SystemStackError, EncodingError => e
|
|
73
|
+
parse_error_paths.push(ErrorData.new(path, e.class, e.message))
|
|
61
74
|
else
|
|
62
|
-
|
|
63
|
-
output(analyzer)
|
|
64
|
-
self.offenses_found = true
|
|
65
|
-
self.offenses_total_count += analyzer.errors.count
|
|
66
|
-
end
|
|
75
|
+
collect_findings(analyzer)
|
|
67
76
|
end
|
|
68
77
|
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
Pathname(ruby_file_path).relative_path_from(root_dir).to_s
|
|
73
|
-
end
|
|
74
|
-
else
|
|
75
|
-
[@path.to_s]
|
|
78
|
+
def collect_findings(analyzer)
|
|
79
|
+
reported_offenses(analyzer).each do |offense|
|
|
80
|
+
findings.push(Finding.from(offense, analyzer.file_path))
|
|
76
81
|
end
|
|
77
82
|
end
|
|
78
83
|
|
|
79
|
-
def
|
|
80
|
-
|
|
84
|
+
def reported_offenses(analyzer)
|
|
85
|
+
ignored = ignored_speedups
|
|
86
|
+
analyzer.errors.reject { |offense| ignored.include?(offense.name) }
|
|
81
87
|
end
|
|
82
88
|
|
|
83
|
-
def
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
file_and_line = "#{analyzer.file_path}:#{line}"
|
|
87
|
-
print "#{Painter.paint(file_and_line, :red)} #{Fastererer::Offense::EXPLANATIONS[error_group_name]}.\n"
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
print "\n"
|
|
92
|
-
end
|
|
89
|
+
def all_files
|
|
90
|
+
return [] if path_missing?
|
|
91
|
+
return [@path.to_s] unless @path.directory?
|
|
93
92
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
93
|
+
# base: takes the directory literally; interpolating it would read [ ] { } * ? as pattern
|
|
94
|
+
Dir.glob('**/*.rb', base: @path.to_s).map do |relative_path|
|
|
95
|
+
Pathname(File.join(@path, relative_path)).cleanpath.to_s
|
|
97
96
|
end
|
|
98
97
|
end
|
|
99
98
|
|
|
100
|
-
def output_parse_errors
|
|
101
|
-
return if parse_error_paths.none?
|
|
102
|
-
|
|
103
|
-
puts 'Fastererer was unable to process some files because the'
|
|
104
|
-
puts 'internal parser is not able to read some characters or'
|
|
105
|
-
puts 'has timed out. Unprocessable files were:'
|
|
106
|
-
puts '-----------------------------------------------------'
|
|
107
|
-
puts parse_error_paths
|
|
108
|
-
puts
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def output_statistics
|
|
112
|
-
puts Statistics.new(self)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def output_unable_to_find_file(path)
|
|
116
|
-
puts Painter.paint("No such file or directory - #{path}", :red)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
99
|
def ignored_speedups
|
|
120
100
|
config.ignored_speedups
|
|
121
101
|
end
|
|
@@ -134,53 +114,4 @@ module Fastererer
|
|
|
134
114
|
"#{file_path} - #{error_class} - #{error_message}"
|
|
135
115
|
end
|
|
136
116
|
end
|
|
137
|
-
|
|
138
|
-
class Statistics
|
|
139
|
-
def initialize(traverser)
|
|
140
|
-
@files_inspected_count = traverser.scannable_files.count
|
|
141
|
-
@offenses_found_count = traverser.offenses_total_count
|
|
142
|
-
@unparsable_files_count = traverser.parse_error_paths.count
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def to_s
|
|
146
|
-
[
|
|
147
|
-
inspected_files_output,
|
|
148
|
-
offenses_found_output,
|
|
149
|
-
unparsable_files_output
|
|
150
|
-
].compact.join(', ')
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def inspected_files_output
|
|
154
|
-
Painter.paint(
|
|
155
|
-
"#{@files_inspected_count} #{pluralize(@files_inspected_count, 'file')} inspected", :green
|
|
156
|
-
)
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def offenses_found_output
|
|
160
|
-
color = @offenses_found_count.zero? ? :green : :red
|
|
161
|
-
|
|
162
|
-
Painter.paint(
|
|
163
|
-
"#{@offenses_found_count} #{pluralize(@offenses_found_count, 'offense')} detected", color
|
|
164
|
-
)
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def unparsable_files_output
|
|
168
|
-
return if @unparsable_files_count.zero?
|
|
169
|
-
|
|
170
|
-
Painter.paint(
|
|
171
|
-
"#{@unparsable_files_count} unparsable #{pluralize(@unparsable_files_count, 'file')} found",
|
|
172
|
-
:red
|
|
173
|
-
)
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def pluralize(count, singular, plural = nil)
|
|
177
|
-
if count == 1
|
|
178
|
-
singular.to_s
|
|
179
|
-
elsif plural
|
|
180
|
-
plural.to_s
|
|
181
|
-
else
|
|
182
|
-
"#{singular}s"
|
|
183
|
-
end
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
117
|
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fastererer
|
|
4
|
+
Finding = Data.define(:path, :line, :rule_key, :rule_name, :description, :url) do
|
|
5
|
+
def self.from(offense, path)
|
|
6
|
+
explanation = offense.explanation
|
|
7
|
+
|
|
8
|
+
new(
|
|
9
|
+
path: path.to_s.scrub,
|
|
10
|
+
line: offense.line_number,
|
|
11
|
+
rule_key: offense.name.to_s,
|
|
12
|
+
rule_name: explanation.rule_name,
|
|
13
|
+
description: explanation.description,
|
|
14
|
+
url: explanation.url
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fastererer
|
|
4
|
+
module Formatters
|
|
5
|
+
class Base
|
|
6
|
+
# The gap at \x09 keeps tabs, which are printable, out of the escaping
|
|
7
|
+
UNSAFE_CHARS = /[\x00-\x08\x0A-\x1F\x7F]/
|
|
8
|
+
private_constant :UNSAFE_CHARS
|
|
9
|
+
|
|
10
|
+
def initialize(out: $stdout, err: $stderr)
|
|
11
|
+
@out = out
|
|
12
|
+
@err = err
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
attr_reader :out, :err
|
|
18
|
+
|
|
19
|
+
# Undecorated on purpose for the machine formats; TextFormatter overrides with color
|
|
20
|
+
def output_diagnostics(report)
|
|
21
|
+
missing_path = report.missing_path
|
|
22
|
+
|
|
23
|
+
err.puts("No such file or directory - #{sanitize(missing_path)}") if missing_path
|
|
24
|
+
report.unparsable_files.each { |unparsable_file| err.puts(sanitize(unparsable_file.to_s)) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sanitize(text)
|
|
28
|
+
text.to_s.scrub.gsub(UNSAFE_CHARS) { |char| format('\\x%02X', char.ord) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
require_relative '../version'
|
|
6
|
+
|
|
7
|
+
module Fastererer
|
|
8
|
+
module Formatters
|
|
9
|
+
# Diagnostics go to stderr so stdout carries nothing but the JSON document
|
|
10
|
+
class JsonFormatter < Base
|
|
11
|
+
def render(report)
|
|
12
|
+
output_diagnostics(report)
|
|
13
|
+
out.puts JSON.pretty_generate(document(report))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def document(report)
|
|
19
|
+
{
|
|
20
|
+
'metadata' => { 'fastererer_version' => Fastererer::VERSION },
|
|
21
|
+
'summary' => summary(report),
|
|
22
|
+
'offenses' => offenses(report)
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def summary(report)
|
|
27
|
+
{
|
|
28
|
+
'offense_count' => report.offenses_detected_count,
|
|
29
|
+
'inspected_file_count' => report.files_inspected_count,
|
|
30
|
+
'unparsable_file_count' => report.unparsable_files.count
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def offenses(report)
|
|
35
|
+
report.findings.sort_by { |finding| [finding.path, finding.line] }.map do |finding|
|
|
36
|
+
{
|
|
37
|
+
'path' => finding.path,
|
|
38
|
+
'line' => finding.line,
|
|
39
|
+
'rule' => finding.rule_name,
|
|
40
|
+
'rule_key' => finding.rule_key,
|
|
41
|
+
'message' => finding.description,
|
|
42
|
+
'url' => finding.url
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
|
|
6
|
+
module Fastererer
|
|
7
|
+
module Formatters
|
|
8
|
+
# Shape is pinned to reviewdog's Diagnostic schema: WARNING severity, start-only range
|
|
9
|
+
# code.value is the rule key, not the display name — the reader pastes it into .fastererer.yml
|
|
10
|
+
class RdjsonlFormatter < Base
|
|
11
|
+
SEVERITY = 'WARNING'
|
|
12
|
+
private_constant :SEVERITY
|
|
13
|
+
|
|
14
|
+
def render(report)
|
|
15
|
+
output_diagnostics(report)
|
|
16
|
+
report.findings.each { |finding| out.puts JSON.generate(diagnostic(finding)) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def diagnostic(finding)
|
|
22
|
+
{
|
|
23
|
+
'message' => finding.description,
|
|
24
|
+
'location' => {
|
|
25
|
+
'path' => finding.path,
|
|
26
|
+
'range' => { 'start' => { 'line' => finding.line } }
|
|
27
|
+
},
|
|
28
|
+
'severity' => SEVERITY,
|
|
29
|
+
'code' => { 'value' => finding.rule_key, 'url' => finding.url }
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
require_relative '../painter'
|
|
5
|
+
require_relative 'text_statistics'
|
|
6
|
+
require_relative '../explanation'
|
|
7
|
+
|
|
8
|
+
module Fastererer
|
|
9
|
+
module Formatters
|
|
10
|
+
class TextFormatter < Base
|
|
11
|
+
def render(report)
|
|
12
|
+
output_offenses(report)
|
|
13
|
+
output_diagnostics(report)
|
|
14
|
+
output_statistics(report)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def output_offenses(report)
|
|
20
|
+
report.findings.group_by(&:path).each_value do |path_findings|
|
|
21
|
+
path_findings.group_by(&:rule_name).each_value { |group| output_group(group) }
|
|
22
|
+
out.puts
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def output_group(group)
|
|
27
|
+
group.each do |finding|
|
|
28
|
+
location = Painter.paint("#{sanitize(finding.path)}:#{finding.line}", :red)
|
|
29
|
+
out.puts "#{location}: #{severity}: #{Explanation.format_line(finding)}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def output_diagnostics(report)
|
|
34
|
+
output_missing_path(report)
|
|
35
|
+
output_parse_errors(report)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def output_missing_path(report)
|
|
39
|
+
return unless report.missing_path
|
|
40
|
+
|
|
41
|
+
message = "No such file or directory - #{sanitize(report.missing_path)}"
|
|
42
|
+
err.puts Painter.paint(message, :red)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def output_parse_errors(report)
|
|
46
|
+
return if report.unparsable_files.none?
|
|
47
|
+
|
|
48
|
+
err.puts 'Fastererer was unable to process some files. Unprocessable files were:'
|
|
49
|
+
err.puts '-----------------------------------------------------'
|
|
50
|
+
report.unparsable_files.each { |unparsable_file| err.puts sanitize(unparsable_file.to_s) }
|
|
51
|
+
err.puts
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def output_statistics(report)
|
|
55
|
+
out.puts TextStatistics.new(report)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def severity
|
|
59
|
+
@severity ||= Painter.paint('W', :magenta)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../painter'
|
|
4
|
+
|
|
5
|
+
module Fastererer
|
|
6
|
+
module Formatters
|
|
7
|
+
class TextStatistics
|
|
8
|
+
def initialize(report)
|
|
9
|
+
@files_inspected_count = report.files_inspected_count
|
|
10
|
+
@offenses_detected_count = report.offenses_detected_count
|
|
11
|
+
@unparsable_files_count = report.unparsable_files.count
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_s
|
|
15
|
+
[inspected_files_output, offenses_detected_output, unparsable_files_output]
|
|
16
|
+
.compact.join(', ')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def inspected_files_output
|
|
20
|
+
count = @files_inspected_count
|
|
21
|
+
|
|
22
|
+
Painter.paint("#{count} #{pluralize(count, 'file')} inspected", :green)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def offenses_detected_output
|
|
26
|
+
count = @offenses_detected_count
|
|
27
|
+
color = count.zero? ? :green : :red
|
|
28
|
+
|
|
29
|
+
Painter.paint("#{count} #{pluralize(count, 'offense')} detected", color)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def unparsable_files_output
|
|
33
|
+
return if @unparsable_files_count.zero?
|
|
34
|
+
|
|
35
|
+
count = @unparsable_files_count
|
|
36
|
+
|
|
37
|
+
Painter.paint("#{count} unparsable #{pluralize(count, 'file')} found", :red)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pluralize(count, singular)
|
|
41
|
+
count == 1 ? singular : "#{singular}s"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'formatters/text_formatter'
|
|
4
|
+
require_relative 'formatters/json_formatter'
|
|
5
|
+
require_relative 'formatters/rdjsonl_formatter'
|
|
6
|
+
|
|
7
|
+
module Fastererer
|
|
8
|
+
class UnknownFormatError < StandardError; end
|
|
9
|
+
|
|
10
|
+
module Formatters
|
|
11
|
+
FORMATS = {
|
|
12
|
+
'text' => TextFormatter,
|
|
13
|
+
'json' => JsonFormatter,
|
|
14
|
+
'rdjsonl' => RdjsonlFormatter
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def self.fetch(name)
|
|
18
|
+
FORMATS.fetch(name) do
|
|
19
|
+
raise UnknownFormatError,
|
|
20
|
+
"Unknown format: #{name.inspect}. Valid formats: #{FORMATS.keys.join(', ')}."
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|