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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +75 -2
  3. data/README.md +97 -8
  4. data/config/locales/en.yml +60 -0
  5. data/lib/fastererer/analyzer.rb +28 -53
  6. data/lib/fastererer/cli.rb +21 -4
  7. data/lib/fastererer/explanation.rb +44 -0
  8. data/lib/fastererer/file_traverser.rb +43 -112
  9. data/lib/fastererer/finding.rb +18 -0
  10. data/lib/fastererer/formatters/base.rb +32 -0
  11. data/lib/fastererer/formatters/json_formatter.rb +48 -0
  12. data/lib/fastererer/formatters/rdjsonl_formatter.rb +34 -0
  13. data/lib/fastererer/formatters/text_formatter.rb +63 -0
  14. data/lib/fastererer/formatters/text_statistics.rb +45 -0
  15. data/lib/fastererer/formatters.rb +24 -0
  16. data/lib/fastererer/method_call.rb +139 -75
  17. data/lib/fastererer/method_definition.rb +61 -48
  18. data/lib/fastererer/offense.rb +5 -63
  19. data/lib/fastererer/offense_collector.rb +1 -1
  20. data/lib/fastererer/painter.rb +2 -1
  21. data/lib/fastererer/parser.rb +8 -4
  22. data/lib/fastererer/receiver.rb +14 -0
  23. data/lib/fastererer/report.rb +10 -0
  24. data/lib/fastererer/rescue_call.rb +10 -12
  25. data/lib/fastererer/rule_catalog.rb +83 -0
  26. data/lib/fastererer/rule_name.rb +17 -0
  27. data/lib/fastererer/scanners/for_loop_scanner.rb +23 -0
  28. data/lib/fastererer/scanners/method_call_scanner.rb +13 -17
  29. data/lib/fastererer/scanners/method_definition_scanner.rb +57 -29
  30. data/lib/fastererer/scanners/offensive.rb +2 -6
  31. data/lib/fastererer/scanners/rescue_call_scanner.rb +1 -1
  32. data/lib/fastererer/scanners/symbol_to_proc_check.rb +21 -8
  33. data/lib/fastererer/version.rb +1 -1
  34. metadata +19 -11
  35. data/.fastererer.yml +0 -27
  36. data/.ruby-version +0 -1
  37. data/CODE_OF_CONDUCT.md +0 -18
  38. data/CONTRIBUTING.md +0 -63
  39. data/Rakefile +0 -12
  40. 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 'painter'
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
- @offenses_total_count = 0
24
+ @formatter = formatter
25
+ @findings = []
24
26
  end
25
27
 
26
28
  def traverse
27
- traverse_files
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
- !!offenses_found
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
- attr_accessor :offenses_found
52
+ attr_reader :findings
47
53
 
48
- def traverse_files
49
- if @path.exist?
50
- scannable_files.each { |ruby_file| scan_file(ruby_file) }
51
- else
52
- output_unable_to_find_file(@path)
53
- end
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 RubyParser::SyntaxError, Racc::ParseError, Timeout::Error => e
60
- parse_error_paths.push(ErrorData.new(path, e.class, e.message).to_s)
72
+ rescue Fastererer::ParseError, SystemCallError, SystemStackError, EncodingError => e
73
+ parse_error_paths.push(ErrorData.new(path, e.class, e.message))
61
74
  else
62
- if offenses_grouped_by_type(analyzer).any?
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 all_files
70
- if @path.directory?
71
- Dir[File.join(@path, '**', '*.rb')].map do |ruby_file_path|
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 root_dir
80
- @root_dir ||= Pathname('.')
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 output(analyzer)
84
- offenses_grouped_by_type(analyzer).each do |error_group_name, error_occurences|
85
- error_occurences.map(&:line_number).each do |line|
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
- def offenses_grouped_by_type(analyzer)
95
- analyzer.errors.group_by(&:name).delete_if do |offense_name, _|
96
- ignored_speedups.include?(offense_name)
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