why-classes 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/LICENSE.txt +21 -0
- data/README.md +128 -0
- data/config/default.yml +69 -0
- data/exe/why-classes +6 -0
- data/lib/why_classes/ast/class_shape.rb +106 -0
- data/lib/why_classes/ast/node_helpers.rb +186 -0
- data/lib/why_classes/cli.rb +97 -0
- data/lib/why_classes/config_loader.rb +51 -0
- data/lib/why_classes/configuration.rb +68 -0
- data/lib/why_classes/correction.rb +44 -0
- data/lib/why_classes/correctors/data_bucket_corrector.rb +79 -0
- data/lib/why_classes/disable_comments.rb +59 -0
- data/lib/why_classes/file_finder.rb +43 -0
- data/lib/why_classes/formatters/base_formatter.rb +30 -0
- data/lib/why_classes/formatters/clang_formatter.rb +19 -0
- data/lib/why_classes/formatters/diff_formatter.rb +107 -0
- data/lib/why_classes/formatters/json_formatter.rb +42 -0
- data/lib/why_classes/formatters/progress_formatter.rb +38 -0
- data/lib/why_classes/offense.rb +29 -0
- data/lib/why_classes/options.rb +32 -0
- data/lib/why_classes/parser.rb +32 -0
- data/lib/why_classes/registry.rb +39 -0
- data/lib/why_classes/rule.rb +89 -0
- data/lib/why_classes/rules/data_bucket.rb +79 -0
- data/lib/why_classes/rules/function_bucket.rb +55 -0
- data/lib/why_classes/rules/inheritance_for_mixins.rb +37 -0
- data/lib/why_classes/rules/invalid_initial_state.rb +56 -0
- data/lib/why_classes/rules/single_attribute_reader.rb +38 -0
- data/lib/why_classes/rules/stateless_singleton_methods.rb +50 -0
- data/lib/why_classes/runner.rb +115 -0
- data/lib/why_classes/source_file.rb +45 -0
- data/lib/why_classes/version.rb +5 -0
- data/lib/why_classes.rb +32 -0
- metadata +113 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhyClasses
|
|
4
|
+
# A merged configuration (project .why-classes.yml over config/default.yml),
|
|
5
|
+
# exposing per-rule lookups and Rails awareness.
|
|
6
|
+
class Configuration
|
|
7
|
+
attr_reader :raw
|
|
8
|
+
|
|
9
|
+
def initialize(data)
|
|
10
|
+
@data = data
|
|
11
|
+
@raw = data
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def all_rules
|
|
15
|
+
@data.fetch("AllRules", {})
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def rules
|
|
19
|
+
@data.fetch("Rules", {})
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def rails_config
|
|
23
|
+
@data.fetch("Rails", {})
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def include_globs
|
|
27
|
+
all_rules.fetch("Include", ["**/*.rb"])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def exclude_globs
|
|
31
|
+
all_rules.fetch("Exclude", [])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def rails?
|
|
35
|
+
all_rules.fetch("Rails", true)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enabled?(rule_name)
|
|
39
|
+
rule_config(rule_name).fetch("Enabled", true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def autocorrect?(rule_name)
|
|
43
|
+
rule_config(rule_name).fetch("Autocorrect", false)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def rule_option(rule_name, key, default = nil)
|
|
47
|
+
rule_config(rule_name).fetch(key, default)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rule_config(rule_name)
|
|
51
|
+
rules.fetch(rule_name, {})
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def framework_base_classes
|
|
55
|
+
rails_config.fetch("FrameworkBaseClasses", [])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def framework_base_class?(const_path)
|
|
59
|
+
return false if const_path.nil?
|
|
60
|
+
|
|
61
|
+
framework_base_classes.include?(const_path)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def mixin_suggestions_for(const_path)
|
|
65
|
+
rails_config.fetch("MixinSuggestions", {}).fetch(const_path, nil)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parser/source/tree_rewriter"
|
|
4
|
+
require_relative "parser"
|
|
5
|
+
|
|
6
|
+
module WhyClasses
|
|
7
|
+
# Applies correctable offenses to a file's source via Parser::Source::TreeRewriter,
|
|
8
|
+
# which shares the coordinate system used for detection. Every rewrite is
|
|
9
|
+
# re-parsed as a fail-safe: if the result no longer parses, it is discarded and
|
|
10
|
+
# the original source is kept (the offense is still reported as advice).
|
|
11
|
+
module Correction
|
|
12
|
+
Result = Data.define(:source, :applied)
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def apply(source_file, correctable_offenses)
|
|
17
|
+
return Result.new(source: source_file.source, applied: 0) if correctable_offenses.empty?
|
|
18
|
+
|
|
19
|
+
rewriter = ::Parser::Source::TreeRewriter.new(source_file.buffer)
|
|
20
|
+
applied = 0
|
|
21
|
+
correctable_offenses.each do |offense|
|
|
22
|
+
offense.corrector.call(rewriter)
|
|
23
|
+
applied += 1
|
|
24
|
+
rescue ::Parser::ClobberingError
|
|
25
|
+
# Overlapping edit -- skip this one, keep the rest.
|
|
26
|
+
next
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
new_source = rewriter.process
|
|
30
|
+
return Result.new(source: source_file.source, applied: 0) unless reparses?(new_source, source_file.path)
|
|
31
|
+
|
|
32
|
+
Result.new(source: new_source, applied: applied)
|
|
33
|
+
rescue ::Parser::ClobberingError
|
|
34
|
+
Result.new(source: source_file.source, applied: 0)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Strict validation: the detection parser is intentionally error-tolerant,
|
|
38
|
+
# so use native Prism (which reports errors) to reject any rewrite that
|
|
39
|
+
# produced syntactically invalid Ruby.
|
|
40
|
+
def reparses?(source, _path)
|
|
41
|
+
::Prism.parse(source).success?
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../ast/node_helpers"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
module Correctors
|
|
7
|
+
# Decides whether a data-bucket class can be mechanically rewritten to a
|
|
8
|
+
# Struct/Data one-liner, and generates the replacement source.
|
|
9
|
+
#
|
|
10
|
+
# Conservative by design: anything it is unsure about is reported as
|
|
11
|
+
# advice-only (analysis returns nil) rather than rewritten incorrectly.
|
|
12
|
+
module DataBucketCorrector
|
|
13
|
+
H = AST::NodeHelpers
|
|
14
|
+
|
|
15
|
+
Analysis = Data.define(:fields, :kind, :keyword) do
|
|
16
|
+
def replacement(name)
|
|
17
|
+
list = fields.map { |f| ":#{f}" }.join(", ")
|
|
18
|
+
case kind
|
|
19
|
+
when :data
|
|
20
|
+
"#{name} = Data.define(#{list})"
|
|
21
|
+
when :struct
|
|
22
|
+
keyword ? "#{name} = Struct.new(#{list}, keyword_init: true)" : "#{name} = Struct.new(#{list})"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Returns an Analysis when the class is a safely-convertible data bucket,
|
|
30
|
+
# else nil (caller downgrades to advice-only).
|
|
31
|
+
def analyze(shape, prefer_data:)
|
|
32
|
+
return nil unless viable?(shape)
|
|
33
|
+
|
|
34
|
+
fields = fields_for(shape)
|
|
35
|
+
return nil if fields.empty?
|
|
36
|
+
|
|
37
|
+
init = shape.initialize_method
|
|
38
|
+
# attr fields must be covered by the constructor's parameters.
|
|
39
|
+
return nil unless shape.attr_fields.all? { |f| fields.include?(f) }
|
|
40
|
+
|
|
41
|
+
keyword = init ? H.keyword_params?(init.children[1]) : false
|
|
42
|
+
Analysis.new(fields: fields, kind: kind_for(shape, prefer_data), keyword: keyword)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The structural preconditions for treating a class as a pure data bucket.
|
|
46
|
+
def viable?(shape)
|
|
47
|
+
return false if shape.module?
|
|
48
|
+
return false if shape.uses_metaprogramming?
|
|
49
|
+
return false unless superclass_ok?(shape)
|
|
50
|
+
return false unless shape.behavior_methods.empty?
|
|
51
|
+
return false unless shape.singleton_methods.empty?
|
|
52
|
+
return false unless shape.other_statements.empty?
|
|
53
|
+
|
|
54
|
+
init = shape.initialize_method
|
|
55
|
+
# No initialize: a bare attr_* container still qualifies.
|
|
56
|
+
return !shape.attr_fields.empty? if init.nil?
|
|
57
|
+
|
|
58
|
+
H.initialize_only_assigns_params?(shape.node)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def superclass_ok?(shape)
|
|
62
|
+
shape.superclass.nil? || shape.superclass == "Object"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def fields_for(shape)
|
|
66
|
+
init = shape.initialize_method
|
|
67
|
+
init ? H.param_names(init.children[1]) : shape.attr_fields
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# attr_accessor/attr_writer imply mutation -> Struct; otherwise Data.
|
|
71
|
+
def kind_for(shape, prefer_data)
|
|
72
|
+
writable = !(shape.attr_macros[:attr_writer] + shape.attr_macros[:attr_accessor]).empty?
|
|
73
|
+
return :struct if writable && !prefer_data
|
|
74
|
+
|
|
75
|
+
:data
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhyClasses
|
|
4
|
+
# Honours inline suppression comments:
|
|
5
|
+
# # why-classes:disable RuleA, RuleB (whole file)
|
|
6
|
+
# # why-classes:disable-line RuleA (that line only)
|
|
7
|
+
# A bare `disable`/`disable-line` with no rule names (or `all`) suppresses
|
|
8
|
+
# every rule.
|
|
9
|
+
module DisableComments
|
|
10
|
+
DIRECTIVE = /#\s*why-classes:disable(?<line>-line)?\s*(?<rules>[\w:,\s]*)$/
|
|
11
|
+
|
|
12
|
+
Directives = Data.define(:file_rules, :line_rules) do
|
|
13
|
+
def allow?(offense)
|
|
14
|
+
return false if suppressed?(file_rules, offense.rule_name)
|
|
15
|
+
|
|
16
|
+
!suppressed?(line_rules.fetch(offense.line, nil), offense.rule_name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def suppressed?(set, rule_name)
|
|
20
|
+
return false if set.nil?
|
|
21
|
+
|
|
22
|
+
set.include?(:all) || set.include?(rule_name)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
def parse(comments)
|
|
29
|
+
file_rules = []
|
|
30
|
+
line_rules = Hash.new { |h, k| h[k] = [] }
|
|
31
|
+
|
|
32
|
+
comments.each do |comment|
|
|
33
|
+
match = DIRECTIVE.match(comment.text)
|
|
34
|
+
next unless match
|
|
35
|
+
|
|
36
|
+
names = rule_names(match[:rules])
|
|
37
|
+
if match[:line]
|
|
38
|
+
line_rules[comment.location.line].concat(names)
|
|
39
|
+
else
|
|
40
|
+
file_rules.concat(names)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
Directives.new(file_rules: file_rules.uniq, line_rules: line_rules.transform_values(&:uniq))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def filter(offenses, comments)
|
|
48
|
+
directives = parse(comments)
|
|
49
|
+
offenses.select { |offense| directives.allow?(offense) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rule_names(raw)
|
|
53
|
+
names = raw.to_s.split(/[,\s]+/).reject(&:empty?)
|
|
54
|
+
return [:all] if names.empty? || names.include?("all")
|
|
55
|
+
|
|
56
|
+
names
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhyClasses
|
|
4
|
+
# Expands CLI paths into a concrete list of Ruby files, honouring the
|
|
5
|
+
# configuration's Include/Exclude globs. Explicitly-named files are always
|
|
6
|
+
# included; Exclude only filters directory expansion.
|
|
7
|
+
module FileFinder
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def find(paths, configuration)
|
|
11
|
+
paths = ["."] if paths.nil? || paths.empty?
|
|
12
|
+
files = []
|
|
13
|
+
paths.each do |path|
|
|
14
|
+
if File.file?(path)
|
|
15
|
+
files << normalize(path)
|
|
16
|
+
elsif File.directory?(path)
|
|
17
|
+
files.concat(expand_dir(path, configuration))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
files.uniq
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def expand_dir(dir, configuration)
|
|
24
|
+
configuration.include_globs
|
|
25
|
+
.flat_map { |glob| Dir.glob(File.join(dir, glob), File::FNM_EXTGLOB) }
|
|
26
|
+
.select { |f| File.file?(f) }
|
|
27
|
+
.map { |f| normalize(f) }
|
|
28
|
+
.reject { |f| excluded?(f, configuration) }
|
|
29
|
+
.uniq
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def excluded?(file, configuration)
|
|
33
|
+
configuration.exclude_globs.any? do |glob|
|
|
34
|
+
File.fnmatch?(glob, file, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
|
|
35
|
+
File.fnmatch?("**/#{glob}", file, File::FNM_PATHNAME | File::FNM_EXTGLOB)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def normalize(path)
|
|
40
|
+
path.delete_prefix("./")
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhyClasses
|
|
4
|
+
module Formatters
|
|
5
|
+
# Formatters receive the array of FileReport objects produced by the Runner
|
|
6
|
+
# and write output to an IO.
|
|
7
|
+
class BaseFormatter
|
|
8
|
+
def initialize(io = $stdout)
|
|
9
|
+
@io = io
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @param reports [Array<Runner::FileReport>]
|
|
13
|
+
def call(reports)
|
|
14
|
+
raise NotImplementedError
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :io
|
|
20
|
+
|
|
21
|
+
def total_offenses(reports)
|
|
22
|
+
reports.sum { |r| r.offenses.size }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def total_corrected(reports)
|
|
26
|
+
reports.sum(&:applied)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_formatter"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
module Formatters
|
|
7
|
+
# One line per offense: `path:line:col: [Rule] message`. Editor/CI friendly.
|
|
8
|
+
class ClangFormatter < BaseFormatter
|
|
9
|
+
def call(reports)
|
|
10
|
+
reports.each do |report|
|
|
11
|
+
report.offenses.each do |offense|
|
|
12
|
+
io.puts "#{report.path}:#{offense.line}:#{offense.column + 1}: " \
|
|
13
|
+
"[#{offense.rule_name}] #{offense.message}"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_formatter"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
module Formatters
|
|
7
|
+
# Shows a unified diff of the proposed rewrites (for --diff / --dry-run).
|
|
8
|
+
# Only files that actually changed are printed.
|
|
9
|
+
class DiffFormatter < BaseFormatter
|
|
10
|
+
def call(reports)
|
|
11
|
+
changed = reports.select(&:changed?)
|
|
12
|
+
changed.each do |report|
|
|
13
|
+
io.puts "--- #{report.path}"
|
|
14
|
+
io.puts "+++ #{report.path}"
|
|
15
|
+
io.print UnifiedDiff.render(report.original_source, report.corrected_source)
|
|
16
|
+
end
|
|
17
|
+
corrections = total_corrected(reports)
|
|
18
|
+
io.puts "#{changed.size} file#{'s' unless changed.size == 1} would change, " \
|
|
19
|
+
"#{corrections} correction#{'s' unless corrections == 1}."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A small LCS-based unified diff over lines. Sufficient for the localized
|
|
23
|
+
# rewrites this tool produces. Ops carry the actual line text.
|
|
24
|
+
module UnifiedDiff
|
|
25
|
+
Op = Struct.new(:kind, :text, :a_line, :b_line) # kind: :eq/:del/:add
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def render(before, after, context: 3)
|
|
30
|
+
a = before.lines
|
|
31
|
+
b = after.lines
|
|
32
|
+
ops = diff_ops(a, b)
|
|
33
|
+
hunks(ops, context).map { |slice| format_hunk(slice) }.join
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def diff_ops(a, b)
|
|
37
|
+
table = lcs_table(a, b)
|
|
38
|
+
ops = []
|
|
39
|
+
i = a.size
|
|
40
|
+
j = b.size
|
|
41
|
+
while i.positive? && j.positive?
|
|
42
|
+
if a[i - 1] == b[j - 1]
|
|
43
|
+
ops.unshift(Op.new(:eq, a[i - 1], i, j))
|
|
44
|
+
i -= 1
|
|
45
|
+
j -= 1
|
|
46
|
+
elsif table[i - 1][j] >= table[i][j - 1]
|
|
47
|
+
ops.unshift(Op.new(:del, a[i - 1], i, nil))
|
|
48
|
+
i -= 1
|
|
49
|
+
else
|
|
50
|
+
ops.unshift(Op.new(:add, b[j - 1], nil, j))
|
|
51
|
+
j -= 1
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
ops.unshift(Op.new(:del, a[(i -= 1)], i + 1, nil)) while i.positive?
|
|
55
|
+
ops.unshift(Op.new(:add, b[(j -= 1)], nil, j + 1)) while j.positive?
|
|
56
|
+
ops
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def lcs_table(a, b)
|
|
60
|
+
table = Array.new(a.size + 1) { Array.new(b.size + 1, 0) }
|
|
61
|
+
a.size.times do |i|
|
|
62
|
+
b.size.times do |j|
|
|
63
|
+
table[i + 1][j + 1] = a[i] == b[j] ? table[i][j] + 1 : [table[i][j + 1], table[i + 1][j]].max
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
table
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Slice ops into hunks around changes, keeping `context` equal lines.
|
|
70
|
+
def hunks(ops, context)
|
|
71
|
+
change_idx = ops.each_index.select { |k| ops[k].kind != :eq }
|
|
72
|
+
return [] if change_idx.empty?
|
|
73
|
+
|
|
74
|
+
ranges = []
|
|
75
|
+
change_idx.each do |k|
|
|
76
|
+
lo = [k - context, 0].max
|
|
77
|
+
hi = [k + context, ops.size - 1].min
|
|
78
|
+
if ranges.any? && lo <= ranges.last[1] + 1
|
|
79
|
+
ranges.last[1] = hi
|
|
80
|
+
else
|
|
81
|
+
ranges << [lo, hi]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
ranges.map { |lo, hi| ops[lo..hi] }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def format_hunk(slice)
|
|
88
|
+
a_lines = slice.select { |op| op.kind != :add }
|
|
89
|
+
b_lines = slice.select { |op| op.kind != :del }
|
|
90
|
+
a_start = a_lines.first&.a_line || 0
|
|
91
|
+
b_start = b_lines.first&.b_line || 0
|
|
92
|
+
header = "@@ -#{a_start},#{a_lines.size} +#{b_start},#{b_lines.size} @@\n"
|
|
93
|
+
body = slice.map { |op| "#{prefix(op.kind)}#{ensure_newline(op.text)}" }.join
|
|
94
|
+
header + body
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def prefix(kind)
|
|
98
|
+
{ eq: " ", del: "-", add: "+" }.fetch(kind)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def ensure_newline(text)
|
|
102
|
+
text.end_with?("\n") ? text : "#{text}\n"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "base_formatter"
|
|
5
|
+
|
|
6
|
+
module WhyClasses
|
|
7
|
+
module Formatters
|
|
8
|
+
# Machine-readable output (editors, LSP bridges, CI dashboards).
|
|
9
|
+
class JsonFormatter < BaseFormatter
|
|
10
|
+
def call(reports)
|
|
11
|
+
payload = {
|
|
12
|
+
"summary" => {
|
|
13
|
+
"files_inspected" => reports.size,
|
|
14
|
+
"offenses" => total_offenses(reports),
|
|
15
|
+
"corrected" => total_corrected(reports)
|
|
16
|
+
},
|
|
17
|
+
"files" => reports.map { |report| file_payload(report) }
|
|
18
|
+
}
|
|
19
|
+
io.puts JSON.pretty_generate(payload)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def file_payload(report)
|
|
25
|
+
{
|
|
26
|
+
"path" => report.path,
|
|
27
|
+
"offenses" => report.offenses.map do |offense|
|
|
28
|
+
{
|
|
29
|
+
"rule" => offense.rule_name,
|
|
30
|
+
"message" => offense.message,
|
|
31
|
+
"suggestion" => offense.suggestion,
|
|
32
|
+
"line" => offense.line,
|
|
33
|
+
"column" => offense.column + 1,
|
|
34
|
+
"severity" => offense.severity.to_s,
|
|
35
|
+
"correctable" => offense.correctable?
|
|
36
|
+
}
|
|
37
|
+
end
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_formatter"
|
|
4
|
+
|
|
5
|
+
module WhyClasses
|
|
6
|
+
module Formatters
|
|
7
|
+
# The default human-readable formatter: one block per offense with its
|
|
8
|
+
# suggestion, then a summary line.
|
|
9
|
+
class ProgressFormatter < BaseFormatter
|
|
10
|
+
def call(reports)
|
|
11
|
+
reports.each do |report|
|
|
12
|
+
report.offenses.each { |offense| print_offense(report, offense) }
|
|
13
|
+
end
|
|
14
|
+
print_summary(reports)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def print_offense(report, offense)
|
|
20
|
+
marker = offense.correctable? ? " [correctable]" : ""
|
|
21
|
+
io.puts "#{report.path}:#{offense.line}:#{offense.column + 1}: " \
|
|
22
|
+
"[#{offense.rule_name}]#{marker} #{offense.message}"
|
|
23
|
+
offense.suggestion.to_s.each_line { |line| io.puts " #{line.chomp}" }
|
|
24
|
+
io.puts
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def print_summary(reports)
|
|
28
|
+
files = reports.size
|
|
29
|
+
offenses = total_offenses(reports)
|
|
30
|
+
corrected = total_corrected(reports)
|
|
31
|
+
summary = "#{files} file#{'s' unless files == 1} inspected, " \
|
|
32
|
+
"#{offenses} offense#{'s' unless offenses == 1} detected"
|
|
33
|
+
summary += ", #{corrected} corrected" if corrected.positive?
|
|
34
|
+
io.puts summary
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhyClasses
|
|
4
|
+
# An immutable finding produced by a Rule.
|
|
5
|
+
#
|
|
6
|
+
# +corrector+ is nil for advice-only offenses, or a callable taking a
|
|
7
|
+
# Parser::Source::TreeRewriter and queueing the edits that apply the
|
|
8
|
+
# suggested refactoring.
|
|
9
|
+
Offense = Data.define(
|
|
10
|
+
:rule_name,
|
|
11
|
+
:message,
|
|
12
|
+
:suggestion,
|
|
13
|
+
:path,
|
|
14
|
+
:line,
|
|
15
|
+
:column,
|
|
16
|
+
:severity,
|
|
17
|
+
:correctable,
|
|
18
|
+
:corrector
|
|
19
|
+
) do
|
|
20
|
+
def correctable?
|
|
21
|
+
correctable && !corrector.nil?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Sort key: by position within a file.
|
|
25
|
+
def position
|
|
26
|
+
[line, column]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhyClasses
|
|
4
|
+
# Parsed CLI options (dogfooding the gem's own advice: a plain Data value).
|
|
5
|
+
#
|
|
6
|
+
# mode: :report | :diff | :fix
|
|
7
|
+
Options = Data.define(
|
|
8
|
+
:paths,
|
|
9
|
+
:mode,
|
|
10
|
+
:fix_unsafe,
|
|
11
|
+
:only,
|
|
12
|
+
:except,
|
|
13
|
+
:format,
|
|
14
|
+
:config_path,
|
|
15
|
+
:rails,
|
|
16
|
+
:fail_level
|
|
17
|
+
) do
|
|
18
|
+
def self.defaults(**overrides)
|
|
19
|
+
new(**{
|
|
20
|
+
paths: [],
|
|
21
|
+
mode: :report,
|
|
22
|
+
fix_unsafe: false,
|
|
23
|
+
only: [],
|
|
24
|
+
except: [],
|
|
25
|
+
format: "progress",
|
|
26
|
+
config_path: nil,
|
|
27
|
+
rails: nil,
|
|
28
|
+
fail_level: "convention"
|
|
29
|
+
}.merge(overrides))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require "prism/translation/parser"
|
|
5
|
+
require "parser/source/tree_rewriter"
|
|
6
|
+
|
|
7
|
+
module WhyClasses
|
|
8
|
+
# Wraps Prism's official parser-compatible frontend so that detection and
|
|
9
|
+
# rewriting share a single coordinate system. Prism is the actual parsing
|
|
10
|
+
# engine under the hood; the +parser+ gem supplies the AST node interface,
|
|
11
|
+
# source buffer, ranges and TreeRewriter.
|
|
12
|
+
module Parser
|
|
13
|
+
Result = Data.define(:ast, :comments, :buffer, :error)
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Parses +source+ and returns a Result. Parse errors are non-fatal: on
|
|
18
|
+
# failure +ast+ is nil and +error+ carries the exception message, so a
|
|
19
|
+
# single unparseable file never aborts a whole run.
|
|
20
|
+
def parse(source, path = "(source)")
|
|
21
|
+
buffer = ::Parser::Source::Buffer.new(path, source: source)
|
|
22
|
+
parser = ::Prism::Translation::Parser.new
|
|
23
|
+
parser.diagnostics.all_errors_are_fatal = false
|
|
24
|
+
parser.diagnostics.ignore_warnings = true
|
|
25
|
+
|
|
26
|
+
ast, comments = parser.parse_with_comments(buffer)
|
|
27
|
+
Result.new(ast: ast, comments: comments, buffer: buffer, error: nil)
|
|
28
|
+
rescue ::Parser::SyntaxError, EncodingError => e
|
|
29
|
+
Result.new(ast: nil, comments: [], buffer: buffer, error: e.message)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module WhyClasses
|
|
4
|
+
# Tracks every Rule subclass and resolves the active set from --only/--except
|
|
5
|
+
# and the configuration.
|
|
6
|
+
module Registry
|
|
7
|
+
@rules = {}
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def register(rule_class)
|
|
12
|
+
@rules[rule_class.rule_name] = rule_class
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def all
|
|
16
|
+
@rules.values
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def names
|
|
20
|
+
@rules.keys
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def fetch(name)
|
|
24
|
+
@rules.fetch(name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def known?(name)
|
|
28
|
+
@rules.key?(name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Resolve the rule classes to run given config + CLI selection.
|
|
32
|
+
def active(configuration:, only: nil, except: nil)
|
|
33
|
+
selected = all
|
|
34
|
+
selected = selected.select { |r| Array(only).include?(r.rule_name) } if only && !only.empty?
|
|
35
|
+
selected = selected.reject { |r| Array(except).include?(r.rule_name) } if except && !except.empty?
|
|
36
|
+
selected.select { |r| configuration.enabled?(r.rule_name) }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|