fastererer 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de024ad42cbd1fb10608607fd4b8f4bbe0c6ff58414f001fea232aee478d2c40
4
- data.tar.gz: 2c2722e7fedbe2d4d8542b41db6ad37d61020bea79b5e437305651549cb8c6d5
3
+ metadata.gz: ba7c93c8bf5af26d77535e03595b6e56720bd31f865d23714a36e02f27c21a1a
4
+ data.tar.gz: 6eedb452c7ec6ac71ff7c9b145060cdccf9a5ab7b6837ad2797e02705fc15957
5
5
  SHA512:
6
- metadata.gz: c67fad403a643c864bc7989ca2bb0346802c143baef0bf5bd211a6b6a294abf81dcea1d031a8cf9adca452ee3d83cf58dc75f04297b2ab05fb87c23cfa73cd01
7
- data.tar.gz: f034537ef76b425c479ff9dff2304859f869543f7bddad5c2596baa68d4d39525584b65f8c132ca7bc7918049408951c2f02446ae8181929e0c6b4f42695043f
6
+ metadata.gz: 62425177d92da9debe1b47babac4f1a634633a6e67c4665c0ed65093932515a35547e067c9f72a60b9abe674c2af367a23bf86204ff994adb6906762075461bd
7
+ data.tar.gz: 30a136f9742eefbd5c1499c4dae8c4d92e64541984f7caed36386e2aecda9b0a90ef1acbbd0c9c0fc5c98f9b4bb653cf685e2049ed76850f7b49f25446c7d6c7
data/CHANGELOG.md CHANGED
@@ -4,10 +4,45 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog] and this project adheres to [Semantic Versioning].
6
6
 
7
- Versions 0.11.0 and earlier were released as [`fasterer`](https://github.com/DamirSvrtan/fasterer), prior to the fork. From 0.12.0 onward, this is `fastererer`, maintained by ExtractableMedia.
7
+ Versions 0.11.0 and earlier were released as [`fasterer`](https://github.com/DamirSvrtan/fasterer),
8
+ prior to the fork. From 0.12.0 onward, this is `fastererer`, maintained by ExtractableMedia.
8
9
 
9
10
  ## [Unreleased]
10
11
 
12
+ ## [1.1.0] - 2026-09-04
13
+
14
+ ### Added
15
+
16
+ - [#40]: A `-f`/`--format` flag with machine-readable output formats alongside the default `text`:
17
+ `json` (one document with a run `summary` and a flat `offenses` array) and `rdjsonl` (the
18
+ reviewdog Diagnostic Format, one record per offense, ready to pipe into reviewdog for inline PR
19
+ comments). Machine formats write only their payload to stdout and route diagnostics to stderr.
20
+
21
+ ### Changed
22
+
23
+ - [#77]: Parse errors and the "No such file or directory" message now go to STDERR instead of
24
+ STDOUT. STDOUT carries only offense lines and the statistics line, so it can be piped, grepped or
25
+ redirected on its own.
26
+ - [#84]: A path that does not exist now exits with status `2` instead of `0`. Status `1` still
27
+ means "offenses were found", so a CI job or wrapper script can tell a mistyped path from a real
28
+ finding. The exit codes are documented in the README.
29
+
30
+ ### Fixed
31
+
32
+ - [#77]: The "N offenses detected" statistics line no longer counts offenses suppressed by
33
+ `speedups: <rule>: false` in `.fastererer.yml`. The total previously included suppressed
34
+ offenses, so it could exceed the number of offense lines printed; the two now agree.
35
+ - [#84]: An unknown flag now prints its message and exits `2` instead of dumping a Ruby backtrace
36
+ and exiting `1`. Exiting `1` made a mistyped flag indistinguishable from a real finding.
37
+ - [#84]: A directory whose name contains glob metacharacters (`pkg[1]`, `a{b}`) is now scanned.
38
+ The name was interpolated into the glob pattern, so such a directory silently reported
39
+ `0 files inspected` and exited `0`. Passing an absolute directory path no longer raises
40
+ `ArgumentError` either.
41
+ - [#84]: A missing path no longer reports a clean scan. `fastererer no_such_path` printed
42
+ `1 file inspected, 0 offenses detected` and exited `0`, so a renamed or mistyped directory in CI
43
+ stopped linting while the build stayed green. The statistics line now reads
44
+ `0 files inspected, 0 offenses detected` and the run fails.
45
+
11
46
  ## [1.0.0] - 2026-06-05
12
47
 
13
48
  First stable release of `fastererer` after the fork.
@@ -136,7 +171,8 @@ First stable release of `fastererer` after the fork.
136
171
 
137
172
  [Keep a Changelog]: https://keepachangelog.com
138
173
  [Semantic Versioning]: https://semver.org/spec/v2.0.0.html
139
- [Unreleased]: https://github.com/ExtractableMedia/fastererer/compare/v1.0.0...HEAD
174
+ [Unreleased]: https://github.com/ExtractableMedia/fastererer/compare/v1.1.0...HEAD
175
+ [1.1.0]: https://github.com/ExtractableMedia/fastererer/compare/v1.0.0...v1.1.0
140
176
  [1.0.0]: https://github.com/ExtractableMedia/fastererer/compare/v0.12.0...v1.0.0
141
177
  [0.12.0]: https://github.com/ExtractableMedia/fastererer/compare/v0.11.0...v0.12.0
142
178
  [#1]: https://github.com/ExtractableMedia/fastererer/pull/1
@@ -144,4 +180,7 @@ First stable release of `fastererer` after the fork.
144
180
  [#9]: https://github.com/ExtractableMedia/fastererer/pull/9
145
181
  [#15]: https://github.com/ExtractableMedia/fastererer/pull/15
146
182
  [#28]: https://github.com/ExtractableMedia/fastererer/issues/28
183
+ [#40]: https://github.com/ExtractableMedia/fastererer/issues/40
147
184
  [#42]: https://github.com/ExtractableMedia/fastererer/pull/42
185
+ [#77]: https://github.com/ExtractableMedia/fastererer/issues/77
186
+ [#84]: https://github.com/ExtractableMedia/fastererer/pull/84
data/README.md CHANGED
@@ -49,7 +49,8 @@ bundle exec fastererer app/models
49
49
  bundle exec fastererer app/models/post.rb
50
50
  ```
51
51
 
52
- Fastererer exits with status `1` when offenses are found, making it suitable for CI.
52
+ Fastererer exits with status `1` when offenses are found and `2` when the path it was given does not
53
+ exist, making it suitable for CI. See [Exit codes](#exit-codes).
53
54
 
54
55
  ## Example output
55
56
 
@@ -75,6 +76,53 @@ underlying snake_case rule key (e.g. `select_first_vs_detect`), which is what
75
76
  you reference in `.fastererer.yml` under `speedups:` to disable a rule.
76
77
  Descriptions and documentation URLs live in `config/locales/en.yml`.
77
78
 
79
+ ## Output formats
80
+
81
+ By default fastererer prints the human-readable text shown above. Pass `-f`/`--format` to emit
82
+ machine-readable output for editors, log aggregators, or CI reporters instead:
83
+
84
+ ```shell
85
+ fastererer --format=json # single JSON document, includes a run summary
86
+ fastererer --format=rdjsonl # reviewdog JSON Lines, one record per offense
87
+ fastererer --format=text # the default
88
+ ```
89
+
90
+ Machine formats write only their payload to stdout; diagnostics (parse errors, a missing path)
91
+ go to stderr, so `fastererer --format=json > findings.json` captures clean JSON. An unrecognized
92
+ format name is a usage error: the name is reported on stderr and fastererer exits `2` without
93
+ scanning.
94
+
95
+ `--format=json` produces one document with a `summary` of run counts and a flat `offenses` array
96
+ (each offense carries `path`, `line`, `rule`, `rule_key`, `message`, and `url`). `rule` is the
97
+ display name; `rule_key` is the identifier to write under `speedups:` in `.fastererer.yml`, so a
98
+ consumer can offer to silence a rule without guessing at the name:
99
+
100
+ ```json
101
+ {
102
+ "metadata": { "fastererer_version": "1.0.0" },
103
+ "summary": { "offense_count": 1, "inspected_file_count": 12, "unparsable_file_count": 0 },
104
+ "offenses": [
105
+ {
106
+ "path": "app/models/post.rb",
107
+ "line": 57,
108
+ "rule": "Performance/SelectFirstVsDetect",
109
+ "rule_key": "select_first_vs_detect",
110
+ "message": "Array#select.first is slower than Array#detect",
111
+ "url": "https://github.com/fastruby/fast-ruby#enumerabledetect-vs-enumerableselectfirst-code"
112
+ }
113
+ ]
114
+ }
115
+ ```
116
+
117
+ `--format=rdjsonl` follows the [reviewdog Diagnostic Format][rdf], one JSON object per line, ready
118
+ to pipe straight into reviewdog (see [CI integration](#ci-integration)). `code.value` carries the
119
+ rule key rather than the display name, because reviewdog renders it into the pull request comment
120
+ and the key is the name a reader can act on — the one to add under `speedups:` in `.fastererer.yml`:
121
+
122
+ ```json
123
+ {"message":"Array#select.first is slower than Array#detect","location":{"path":"app/models/post.rb","range":{"start":{"line":57}}},"severity":"WARNING","code":{"value":"select_first_vs_detect","url":"https://github.com/fastruby/fast-ruby#enumerabledetect-vs-enumerableselectfirst-code"}}
124
+ ```
125
+
78
126
  ## Configuration
79
127
 
80
128
  Configuration lives in a `.fastererer.yml` file at the root of your project (or any ancestor
@@ -122,6 +170,38 @@ step:
122
170
  run: bundle exec fastererer
123
171
  ```
124
172
 
173
+ ### Exit codes
174
+
175
+ | Status | Meaning |
176
+ | ------ | ------- |
177
+ | `0` | The scan completed and no offenses were found |
178
+ | `1` | The scan completed and offenses were found |
179
+ | `2` | Usage error — an unknown flag or format, or a path that does not exist, so nothing was scanned |
180
+
181
+ Status `2` is kept distinct from `1` so a wrapper script can tell "the tool ran and found problems"
182
+ from "you invoked me wrong". A renamed directory, a mistyped flag or an unknown `--format` value
183
+ fails the build instead of reporting a clean scan.
184
+
185
+ ### Inline PR comments with reviewdog
186
+
187
+ The `rdjsonl` format is consumed natively by [reviewdog](https://github.com/reviewdog/reviewdog),
188
+ which can post findings as inline GitHub PR review comments:
189
+
190
+ ```shell
191
+ bundle exec fastererer --format=rdjsonl \
192
+ | reviewdog -f=rdjsonl -name=fastererer -filter-mode=nofilter -reporter=github-pr-review
193
+ ```
194
+
195
+ `-filter-mode=nofilter` reports every finding, not only those on lines the pull request touched,
196
+ which is what reviewdog does by default. Findings outside the diff cannot be posted as inline
197
+ review comments — GitHub's review API does not allow it — so reviewdog falls back to check
198
+ annotations for those. `-name=fastererer` attributes the comments, since the rdjsonl record carries
199
+ no tool name of its own. `-reporter=github-pr-review` needs `pull-requests: write`.
200
+
201
+ Run fastererer from the repository root and let the path default to `.`. reviewdog matches findings
202
+ against the pull request by repository-relative path, so passing an absolute directory emits
203
+ absolute paths that will not match — and puts the runner's directory layout in a public comment.
204
+
125
205
  Color output is auto-disabled when STDOUT isn't a TTY, when `NO_COLOR` is set (see
126
206
  [no-color.org](https://no-color.org/)), or when `--no-color` is passed — so CI logs, piped output
127
207
  (`fastererer | grep`), and editor integrations get plain text without configuration.
@@ -173,5 +253,6 @@ for the idiom catalog that drives the speed checks.
173
253
  [fasterer]: https://github.com/DamirSvrtan/fasterer
174
254
  [issues]: https://github.com/ExtractableMedia/fastererer/issues
175
255
  [prism]: https://github.com/ruby/prism
256
+ [rdf]: https://github.com/reviewdog/reviewdog/tree/master/proto/rdf
176
257
  [roadmap-project]: https://github.com/orgs/ExtractableMedia/projects/1
177
258
  [sferik-talk]: https://speakerdeck.com/sferik/writing-fast-ruby
@@ -8,8 +8,9 @@ require 'fastererer/offense'
8
8
  require 'fastererer/offense_collector'
9
9
  require 'fastererer/parser'
10
10
  require 'fastererer/scanners/method_call_scanner'
11
- require 'fastererer/scanners/rescue_call_scanner'
12
11
  require 'fastererer/scanners/method_definition_scanner'
12
+ require 'fastererer/scanners/for_loop_scanner'
13
+ require 'fastererer/scanners/rescue_call_scanner'
13
14
 
14
15
  module Fastererer
15
16
  class Analyzer
@@ -48,7 +49,7 @@ module Fastererer
48
49
  end
49
50
 
50
51
  def visit_for_node(node)
51
- @offenses.push(Fastererer::Offense.new(:for_loop_vs_each, node.location.start_line))
52
+ collect(ForLoopScanner.new(node))
52
53
  super
53
54
  end
54
55
 
@@ -2,17 +2,31 @@
2
2
 
3
3
  require 'optparse'
4
4
  require_relative 'file_traverser'
5
+ require_relative 'formatters'
5
6
  require_relative 'painter'
6
7
  require_relative 'version'
7
8
 
8
9
  module Fastererer
9
10
  class CLI
10
- def self.execute
11
+ OFFENSES_FOUND_STATUS = 1
12
+ USAGE_ERROR_STATUS = 2
13
+
14
+ def self.execute(out: $stdout, err: $stderr)
11
15
  options = parse_options(ARGV.dup)
16
+ formatter = Formatters.fetch(options[:format] || 'text').new(out:, err:)
17
+ rescue UnknownFormatError, OptionParser::ParseError => e
18
+ err.puts(e.message)
19
+ exit USAGE_ERROR_STATUS
20
+ else
12
21
  Painter.disable! if options[:no_color]
13
- file_traverser = Fastererer::FileTraverser.new(options[:path])
22
+ file_traverser = FileTraverser.new(options[:path], formatter:)
14
23
  file_traverser.traverse
15
- abort if file_traverser.offenses_found?
24
+ exit_with_status_for(file_traverser)
25
+ end
26
+
27
+ def self.exit_with_status_for(file_traverser)
28
+ exit USAGE_ERROR_STATUS if file_traverser.path_missing?
29
+ exit OFFENSES_FOUND_STATUS if file_traverser.offenses_found?
16
30
  end
17
31
 
18
32
  def self.parse_options(argv)
@@ -24,6 +38,9 @@ module Fastererer
24
38
  def self.build_parser(options)
25
39
  OptionParser.new do |opts|
26
40
  opts.banner = 'Usage: fastererer [options] [path]'
41
+ opts.on('-f', '--format FORMAT', 'Output format: text (default), json, rdjsonl') do |format|
42
+ options[:format] = format
43
+ end
27
44
  opts.on('--no-color', 'Disable ANSI color in output') { options[:no_color] = true }
28
45
  opts.on('-h', '--help', 'Show this help') { show_help_and_exit(opts) }
29
46
  opts.on('-v', '--version', 'Show version') { show_version_and_exit }
@@ -36,7 +53,7 @@ module Fastererer
36
53
  end
37
54
 
38
55
  def self.show_version_and_exit
39
- puts Fastererer::VERSION
56
+ puts VERSION
40
57
  exit
41
58
  end
42
59
  end
@@ -10,6 +10,10 @@ module Fastererer
10
10
  @instances[offense_name.to_sym] ||= new(offense_name)
11
11
  end
12
12
 
13
+ def self.format_line(source)
14
+ "#{source.rule_name}: #{source.description.delete_suffix('.')}. (#{source.url})"
15
+ end
16
+
13
17
  attr_reader :offense_name
14
18
 
15
19
  def initialize(offense_name)
@@ -30,7 +34,7 @@ module Fastererer
30
34
  end
31
35
 
32
36
  def to_s
33
- "#{rule_name}: #{description.delete_suffix('.')}. (#{url})"
37
+ self.class.format_line(self)
34
38
  end
35
39
 
36
40
  private
@@ -5,8 +5,9 @@ require 'English'
5
5
 
6
6
  require_relative 'analyzer'
7
7
  require_relative 'config'
8
- require_relative 'explanation'
9
- require_relative 'painter'
8
+ require_relative 'finding'
9
+ require_relative 'report'
10
+ require_relative 'formatters/text_formatter'
10
11
 
11
12
  module Fastererer
12
13
  class FileTraverser
@@ -15,19 +16,17 @@ module Fastererer
15
16
  EXCLUDE_PATHS_KEY = Config::EXCLUDE_PATHS_KEY
16
17
 
17
18
  attr_reader :config, :parse_error_paths
18
- attr_accessor :offenses_total_count
19
19
 
20
- def initialize(path)
20
+ def initialize(path, formatter: Formatters::TextFormatter.new)
21
21
  @path = Pathname(path || '.')
22
22
  @parse_error_paths = []
23
23
  @config = Config.new
24
- @offenses_total_count = 0
24
+ @formatter = formatter
25
+ @findings = []
25
26
  end
26
27
 
27
28
  def traverse
28
- traverse_files
29
- output_parse_errors
30
- output_statistics
29
+ @formatter.render(build_report)
31
30
  end
32
31
 
33
32
  def config_file
@@ -35,7 +34,13 @@ module Fastererer
35
34
  end
36
35
 
37
36
  def offenses_found?
38
- !!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?
39
44
  end
40
45
 
41
46
  def scannable_files
@@ -44,83 +49,53 @@ module Fastererer
44
49
 
45
50
  private
46
51
 
47
- attr_accessor :offenses_found
52
+ attr_reader :findings
48
53
 
49
- def traverse_files
50
- if @path.exist?
51
- scannable_files.each { |ruby_file| scan_file(ruby_file) }
52
- else
53
- output_unable_to_find_file(@path)
54
- 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) }
55
67
  end
56
68
 
57
69
  def scan_file(path)
58
70
  analyzer = Analyzer.new(path)
59
71
  analyzer.scan
60
72
  rescue Fastererer::ParseError, SystemCallError, SystemStackError, EncodingError => e
61
- parse_error_paths.push(ErrorData.new(path, e.class, e.message).to_s)
73
+ parse_error_paths.push(ErrorData.new(path, e.class, e.message))
62
74
  else
63
- if offenses_grouped_by_type(analyzer).any?
64
- output(analyzer)
65
- self.offenses_found = true
66
- self.offenses_total_count += analyzer.errors.count
67
- end
75
+ collect_findings(analyzer)
68
76
  end
69
77
 
70
- def all_files
71
- if @path.directory?
72
- Dir[File.join(@path, '**', '*.rb')].map do |ruby_file_path|
73
- Pathname(ruby_file_path).relative_path_from(root_dir).to_s
74
- end
75
- else
76
- [@path.to_s]
78
+ def collect_findings(analyzer)
79
+ reported_offenses(analyzer).each do |offense|
80
+ findings.push(Finding.from(offense, analyzer.file_path))
77
81
  end
78
82
  end
79
83
 
80
- def root_dir
81
- @root_dir ||= Pathname('.')
82
- end
83
-
84
- def output(analyzer)
85
- offenses_grouped_by_type(analyzer).each_value do |error_occurrences|
86
- explanation = error_occurrences.first.explanation
87
-
88
- error_occurrences.map(&:line_number).each do |line|
89
- file_and_line = "#{analyzer.file_path}:#{line}"
90
- print "#{Painter.paint(file_and_line, :red)}: #{severity}: #{explanation}\n"
91
- end
92
- end
93
-
94
- print "\n"
84
+ def reported_offenses(analyzer)
85
+ ignored = ignored_speedups
86
+ analyzer.errors.reject { |offense| ignored.include?(offense.name) }
95
87
  end
96
88
 
97
- def severity
98
- @severity ||= Painter.paint('W', :magenta)
99
- end
89
+ def all_files
90
+ return [] if path_missing?
91
+ return [@path.to_s] unless @path.directory?
100
92
 
101
- def offenses_grouped_by_type(analyzer)
102
- analyzer.errors.group_by(&:name).delete_if do |offense_name, _|
103
- 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
104
96
  end
105
97
  end
106
98
 
107
- def output_parse_errors
108
- return if parse_error_paths.none?
109
-
110
- puts 'Fastererer was unable to process some files. Unprocessable files were:'
111
- puts '-----------------------------------------------------'
112
- puts parse_error_paths
113
- puts
114
- end
115
-
116
- def output_statistics
117
- puts Statistics.new(self)
118
- end
119
-
120
- def output_unable_to_find_file(path)
121
- puts Painter.paint("No such file or directory - #{path}", :red)
122
- end
123
-
124
99
  def ignored_speedups
125
100
  config.ignored_speedups
126
101
  end
@@ -139,53 +114,4 @@ module Fastererer
139
114
  "#{file_path} - #{error_class} - #{error_message}"
140
115
  end
141
116
  end
142
-
143
- class Statistics
144
- def initialize(traverser)
145
- @files_inspected_count = traverser.scannable_files.count
146
- @offenses_found_count = traverser.offenses_total_count
147
- @unparsable_files_count = traverser.parse_error_paths.count
148
- end
149
-
150
- def to_s
151
- [
152
- inspected_files_output,
153
- offenses_found_output,
154
- unparsable_files_output
155
- ].compact.join(', ')
156
- end
157
-
158
- def inspected_files_output
159
- Painter.paint(
160
- "#{@files_inspected_count} #{pluralize(@files_inspected_count, 'file')} inspected", :green
161
- )
162
- end
163
-
164
- def offenses_found_output
165
- color = @offenses_found_count.zero? ? :green : :red
166
-
167
- Painter.paint(
168
- "#{@offenses_found_count} #{pluralize(@offenses_found_count, 'offense')} detected", color
169
- )
170
- end
171
-
172
- def unparsable_files_output
173
- return if @unparsable_files_count.zero?
174
-
175
- Painter.paint(
176
- "#{@unparsable_files_count} unparsable #{pluralize(@unparsable_files_count, 'file')} found",
177
- :red
178
- )
179
- end
180
-
181
- def pluralize(count, singular, plural = nil)
182
- if count == 1
183
- singular.to_s
184
- elsif plural
185
- plural.to_s
186
- else
187
- "#{singular}s"
188
- end
189
- end
190
- end
191
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
@@ -1,9 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'prism'
4
+ require 'fastererer/receiver'
4
5
 
5
6
  module Fastererer
6
7
  class MethodCall
8
+ include Receiver
9
+
7
10
  attr_reader :element
8
11
 
9
12
  def self.build(node)
@@ -26,6 +29,8 @@ module Fastererer
26
29
  method_name
27
30
  end
28
31
 
32
+ def call_name = method_name
33
+
29
34
  def arguments
30
35
  @arguments ||= argument_nodes.map { |argument| Argument.new(argument) }
31
36
  end
@@ -75,7 +80,7 @@ module Fastererer
75
80
  end
76
81
 
77
82
  # Only single positional params convert to &:sym, so a splat or keyword param must not count
78
- # toward block_argument_names.one?
83
+ # toward the single-name check
79
84
  def positional_block_parameter_names
80
85
  return [] unless block_node.is_a?(Prism::BlockNode)
81
86
 
@@ -130,16 +135,13 @@ module Fastererer
130
135
  end
131
136
 
132
137
  class VariableReference
138
+ include Receiver
139
+
133
140
  attr_reader :name
134
141
 
135
142
  def initialize(node)
136
143
  @name = node.name
137
144
  end
138
-
139
- # A bare local/constant reference can't be proven to hold a Hash
140
- def hash?
141
- false
142
- end
143
145
  end
144
146
 
145
147
  class Argument
@@ -180,6 +182,8 @@ module Fastererer
180
182
  end
181
183
 
182
184
  class Primitive
185
+ include Receiver
186
+
183
187
  attr_reader :element
184
188
 
185
189
  def initialize(node)
@@ -14,6 +14,6 @@ module Fastererer
14
14
  @offenses.select { |offense| offense.name == offense_name }
15
15
  end
16
16
 
17
- def_delegators :@offenses, :push, :any?, :each, :group_by, :count
17
+ def_delegators :@offenses, :push, :any?, :each, :group_by, :count, :reject
18
18
  end
19
19
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ # Query surface every receiver answers; includers override only what is true for their type
5
+ module Receiver
6
+ def call_name = nil
7
+ def name = nil
8
+ def block? = false
9
+ def arguments = []
10
+ def range? = false
11
+ def array? = false
12
+ def hash? = false
13
+ end
14
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fastererer
4
+ # Findings arrive already filtered by ignored speedups; formatters must not filter again
5
+ Report = Data.define(:findings, :files_inspected_count, :unparsable_files, :missing_path) do
6
+ def offenses_detected_count
7
+ findings.count
8
+ end
9
+ end
10
+ end
@@ -8,6 +8,8 @@ module Fastererer
8
8
  # Loads, validates, memoizes and looks up the rule catalog from the i18n locale.
9
9
  module RuleCatalog
10
10
  LOCALE_PATH = File.expand_path('../../config/locales/en.yml', __dir__)
11
+ KEY_SHAPE = /\A[a-z0-9_]+\z/
12
+ private_constant :KEY_SHAPE
11
13
 
12
14
  class << self
13
15
  def all
@@ -48,6 +50,9 @@ module Fastererer
48
50
 
49
51
  def validate_row!(key, row)
50
52
  raise "Fastererer rule #{key} is malformed: #{row.inspect}" unless row.is_a?(Hash)
53
+ unless key.is_a?(String) && key.match?(KEY_SHAPE)
54
+ raise "Fastererer rule #{key.inspect} is not a snake_case key"
55
+ end
51
56
 
52
57
  validate_url!(key, row['url'])
53
58
  validate_description!(key, row['description'])
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fastererer/offense'
4
+ require 'fastererer/scanners/offensive'
5
+
6
+ module Fastererer
7
+ class ForLoopScanner
8
+ include Fastererer::Offensive
9
+
10
+ attr_reader :element
11
+
12
+ def initialize(element)
13
+ @element = element
14
+ check_offense
15
+ end
16
+
17
+ private
18
+
19
+ def check_offense
20
+ add_offense(:for_loop_vs_each) # for is always slower than each, so no guard
21
+ end
22
+ end
23
+ end
@@ -75,9 +75,7 @@ module Fastererer
75
75
  end
76
76
 
77
77
  def check_first_offense
78
- return unless method_call.receiver.is_a?(MethodCall)
79
-
80
- case method_call.receiver.name
78
+ case method_call.receiver&.call_name
81
79
  when :shuffle
82
80
  add_offense(:shuffle_first_vs_sample)
83
81
  when :select
@@ -89,9 +87,7 @@ module Fastererer
89
87
  end
90
88
 
91
89
  def check_each_offense
92
- return unless method_call.receiver.is_a?(MethodCall)
93
-
94
- case method_call.receiver.name
90
+ case method_call.receiver&.call_name
95
91
  when :reverse
96
92
  add_offense(:reverse_each_vs_reverse_each)
97
93
  when :keys
@@ -100,8 +96,7 @@ module Fastererer
100
96
  end
101
97
 
102
98
  def check_flatten_offense
103
- return unless method_call.receiver.is_a?(MethodCall)
104
- return unless method_call.receiver.name == :map && method_call.arguments.one?
99
+ return unless method_call.receiver&.call_name == :map && method_call.arguments.one?
105
100
 
106
101
  add_offense(:map_flatten_vs_flat_map) if method_call.arguments.first.value == 1
107
102
  end
@@ -127,17 +122,14 @@ module Fastererer
127
122
  end
128
123
 
129
124
  def check_last_offense
130
- return unless method_call.receiver.is_a?(MethodCall)
131
- return unless method_call.receiver.name == :select
125
+ return unless method_call.receiver&.call_name == :select
132
126
  return if method_call.arguments.any?
133
127
 
134
128
  add_offense(:select_last_vs_reverse_detect)
135
129
  end
136
130
 
137
131
  def check_range_include_offense
138
- return unless method_call.receiver.is_a?(Primitive) && method_call.receiver.range?
139
-
140
- add_offense(:include_vs_cover_on_range)
132
+ add_offense(:include_vs_cover_on_range) if method_call.receiver&.range?
141
133
  end
142
134
  end
143
135
  end
@@ -19,12 +19,18 @@ module Fastererer
19
19
  end
20
20
 
21
21
  def symbol_to_proc_candidate?
22
- method_call.block_argument_names.one? &&
22
+ single_block_argument_name &&
23
23
  single_call_body? &&
24
24
  method_call.arguments.none? &&
25
25
  !method_call.lambda_literal?
26
26
  end
27
27
 
28
+ # A destructured param has no name, so |(a, b), c| must not read as a single-name block
29
+ def single_block_argument_name
30
+ names = method_call.block_argument_names
31
+ names.first if names.size == 1
32
+ end
33
+
28
34
  # A safe-nav body (foo&.bar) is excluded: arr.map(&:bar) raises on a nil element, so the rewrite
29
35
  # would not preserve behavior
30
36
  def single_call_body?
@@ -32,12 +38,11 @@ module Fastererer
32
38
  body&.size == 1 && body.first.is_a?(Prism::CallNode) && !body.first.safe_navigation?
33
39
  end
34
40
 
41
+ # A primitive receiver's name is nil, which never matches the named param the gate requires
35
42
  def symbol_to_proc_body?(body_call)
36
43
  body_call.arguments.none? &&
37
44
  !body_call.block? &&
38
- !body_call.receiver.nil? &&
39
- !body_call.receiver.is_a?(Fastererer::Primitive) &&
40
- body_call.receiver.name == method_call.block_argument_names.first
45
+ body_call.receiver&.name == single_block_argument_name
41
46
  end
42
47
  end
43
48
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fastererer
4
- VERSION = '1.0.0'
4
+ VERSION = '1.1.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fastererer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Matt Menefee
@@ -46,15 +46,25 @@ files:
46
46
  - lib/fastererer/config.rb
47
47
  - lib/fastererer/explanation.rb
48
48
  - lib/fastererer/file_traverser.rb
49
+ - lib/fastererer/finding.rb
50
+ - lib/fastererer/formatters.rb
51
+ - lib/fastererer/formatters/base.rb
52
+ - lib/fastererer/formatters/json_formatter.rb
53
+ - lib/fastererer/formatters/rdjsonl_formatter.rb
54
+ - lib/fastererer/formatters/text_formatter.rb
55
+ - lib/fastererer/formatters/text_statistics.rb
49
56
  - lib/fastererer/method_call.rb
50
57
  - lib/fastererer/method_definition.rb
51
58
  - lib/fastererer/offense.rb
52
59
  - lib/fastererer/offense_collector.rb
53
60
  - lib/fastererer/painter.rb
54
61
  - lib/fastererer/parser.rb
62
+ - lib/fastererer/receiver.rb
63
+ - lib/fastererer/report.rb
55
64
  - lib/fastererer/rescue_call.rb
56
65
  - lib/fastererer/rule_catalog.rb
57
66
  - lib/fastererer/rule_name.rb
67
+ - lib/fastererer/scanners/for_loop_scanner.rb
58
68
  - lib/fastererer/scanners/method_call_scanner.rb
59
69
  - lib/fastererer/scanners/method_definition_scanner.rb
60
70
  - lib/fastererer/scanners/offensive.rb
@@ -84,7 +94,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
84
94
  - !ruby/object:Gem::Version
85
95
  version: '0'
86
96
  requirements: []
87
- rubygems_version: 4.0.10
97
+ rubygems_version: 4.0.16
88
98
  specification_version: 4
89
99
  summary: Run Ruby more than fast. Fastererer
90
100
  test_files: []