pronto-rubycritic 0.12.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/.rubycritic-pronto.yml +65 -0
- data/CHANGELOG.md +102 -0
- data/CONTRIBUTING.md +22 -0
- data/LICENSE +21 -0
- data/README.md +342 -0
- data/lib/pronto/rubycritic/analyser.rb +32 -0
- data/lib/pronto/rubycritic/config_loader.rb +84 -0
- data/lib/pronto/rubycritic/formatter.rb +168 -0
- data/lib/pronto/rubycritic/message_builder.rb +82 -0
- data/lib/pronto/rubycritic/smell_filter.rb +152 -0
- data/lib/pronto/rubycritic/version.rb +11 -0
- data/lib/pronto/rubycritic.rb +113 -0
- data/pronto-rubycritic.gemspec +54 -0
- metadata +123 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pronto
|
|
4
|
+
class RubyCritic < Runner
|
|
5
|
+
# Chooses output markup based on CI environment. GitHub renders rich
|
|
6
|
+
# markdown (headings, tables, <details>) in PR review comments; GitLab
|
|
7
|
+
# does not render <details> reliably in MR / commit comments so plain
|
|
8
|
+
# markdown is used there.
|
|
9
|
+
class Formatter
|
|
10
|
+
GITHUB = :github
|
|
11
|
+
GITLAB = :gitlab
|
|
12
|
+
PLAIN = :plain
|
|
13
|
+
|
|
14
|
+
SEVERITY_EMOJI = {
|
|
15
|
+
info: '💡',
|
|
16
|
+
warning: '⚠️',
|
|
17
|
+
error: '🔴',
|
|
18
|
+
fatal: '🚨'
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
BRAND_NAME = 'pronto-rubycritic'
|
|
22
|
+
BRAND_URL = 'https://github.com/Rishabhs343/custom-pronto-gem'
|
|
23
|
+
|
|
24
|
+
def self.detect(env: ENV, severity: :warning)
|
|
25
|
+
style = if env['GITHUB_ACTIONS'] then GITHUB
|
|
26
|
+
elsif env['GITLAB_CI'] then GITLAB
|
|
27
|
+
else PLAIN
|
|
28
|
+
end
|
|
29
|
+
new(style: style, severity: severity)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(style: PLAIN, severity: :warning)
|
|
33
|
+
@style = style
|
|
34
|
+
@severity = severity
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
attr_reader :style, :severity
|
|
38
|
+
|
|
39
|
+
def call(mod, smell)
|
|
40
|
+
case @style
|
|
41
|
+
when GITHUB then format_github(mod, smell)
|
|
42
|
+
when GITLAB then format_gitlab(mod, smell)
|
|
43
|
+
else format_plain(mod, smell)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def format_github(mod, smell)
|
|
50
|
+
<<~MARKDOWN.strip
|
|
51
|
+
#{severity_emoji} **#{escape_html(smell.type)}** · `#{escape_html(smell.context)}` <sub>#{analyser_badge(smell)}</sub>
|
|
52
|
+
|
|
53
|
+
> #{escape_html(smell.message)}
|
|
54
|
+
|
|
55
|
+
<details><summary>📊 Module metrics</summary>
|
|
56
|
+
|
|
57
|
+
| Metric | Value |
|
|
58
|
+
|---|---:|
|
|
59
|
+
| Complexity | #{fmt(mod.complexity)} |
|
|
60
|
+
| Duplication | #{fmt(mod.duplication)} |
|
|
61
|
+
| Methods | #{fmt(mod.methods_count)} |
|
|
62
|
+
| Cost | #{fmt(mod.cost)} |
|
|
63
|
+
| Churn | #{fmt(mod.churn)} |
|
|
64
|
+
|
|
65
|
+
</details>
|
|
66
|
+
|
|
67
|
+
#{doc_link(smell)}
|
|
68
|
+
|
|
69
|
+
<sub>🧰 reported by <a href="#{BRAND_URL}"><b>#{BRAND_NAME}</b></a> · severity: <code>#{@severity}</code></sub>
|
|
70
|
+
MARKDOWN
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def format_gitlab(mod, smell)
|
|
74
|
+
# GitLab MR comments don't render <details> reliably — use a flat
|
|
75
|
+
# markdown layout that still scans well in GitLab discussion threads.
|
|
76
|
+
<<~MARKDOWN.strip
|
|
77
|
+
#{severity_emoji} **#{safe(smell.type)}** · `#{safe(smell.context)}` (_#{analyser_text(smell)}_)
|
|
78
|
+
|
|
79
|
+
> #{safe(smell.message)}
|
|
80
|
+
|
|
81
|
+
| Complexity | Duplication | Methods | Cost | Churn |
|
|
82
|
+
|---:|---:|---:|---:|---:|
|
|
83
|
+
| #{fmt(mod.complexity)} | #{fmt(mod.duplication)} | #{fmt(mod.methods_count)} | #{fmt(mod.cost)} | #{fmt(mod.churn)} |
|
|
84
|
+
|
|
85
|
+
#{doc_link(smell)}
|
|
86
|
+
|
|
87
|
+
_reported by [**#{BRAND_NAME}**](#{BRAND_URL}) · severity: `#{@severity}`_
|
|
88
|
+
MARKDOWN
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def format_plain(mod, smell)
|
|
92
|
+
doc = doc_url_of(smell)
|
|
93
|
+
lines = [
|
|
94
|
+
"[#{@severity}] #{safe(smell.type)} — #{safe(smell.context)} (#{analyser_text(smell)})",
|
|
95
|
+
" Message: #{safe(smell.message)}",
|
|
96
|
+
" Locations: #{format_locations(smell)}",
|
|
97
|
+
" Complexity: #{fmt(mod.complexity)} Duplication: #{fmt(mod.duplication)} " \
|
|
98
|
+
"Methods: #{fmt(mod.methods_count)} Cost: #{fmt(mod.cost)} Churn: #{fmt(mod.churn)}"
|
|
99
|
+
]
|
|
100
|
+
lines << " Docs: #{doc}" unless doc.nil? || doc.to_s.empty?
|
|
101
|
+
lines.join("\n")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def severity_emoji
|
|
105
|
+
SEVERITY_EMOJI.fetch(@severity, SEVERITY_EMOJI[:warning])
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def analyser_badge(smell)
|
|
109
|
+
"analyser: <code>#{analyser_text(smell)}</code>"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def analyser_text(smell)
|
|
113
|
+
return 'unknown' unless smell.respond_to?(:analyser)
|
|
114
|
+
|
|
115
|
+
value = smell.analyser.to_s
|
|
116
|
+
value.empty? ? 'unknown' : value
|
|
117
|
+
rescue StandardError
|
|
118
|
+
'unknown'
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def doc_link(smell)
|
|
122
|
+
url = doc_url_of(smell)
|
|
123
|
+
return '' if url.nil? || url.to_s.empty?
|
|
124
|
+
|
|
125
|
+
"📚 [Docs for **#{safe(smell.type)}** →](#{url})"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def format_locations(smell)
|
|
129
|
+
locations = Array(smell.locations)
|
|
130
|
+
return 'N/A' if locations.empty?
|
|
131
|
+
|
|
132
|
+
locations.map { |l| "#{l.pathname}:#{l.line}" }.join(', ')
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def doc_url_of(smell)
|
|
136
|
+
smell.respond_to?(:doc_url) ? smell.doc_url : nil
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def fmt(value)
|
|
142
|
+
return 'N/A' if value.nil?
|
|
143
|
+
|
|
144
|
+
str = value.to_s
|
|
145
|
+
return 'N/A' if str.empty?
|
|
146
|
+
return str unless value.is_a?(Float)
|
|
147
|
+
return str if value.nan? || value.infinite?
|
|
148
|
+
|
|
149
|
+
format('%.2f', value)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def safe(value)
|
|
153
|
+
return 'N/A' if value.nil?
|
|
154
|
+
|
|
155
|
+
str = value.to_s
|
|
156
|
+
str.empty? ? 'N/A' : str
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# GitHub renders smell text inside HTML (<details>, <sub>, code spans).
|
|
160
|
+
# Smell context/message come from analysed source (method names — which
|
|
161
|
+
# can be operators like `<=>` — and free-text), so escape the HTML-
|
|
162
|
+
# significant characters to stop them breaking the comment's structure.
|
|
163
|
+
def escape_html(value)
|
|
164
|
+
safe(value).gsub('&', '&').gsub('<', '<').gsub('>', '>')
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Pronto
|
|
6
|
+
class RubyCritic < Runner
|
|
7
|
+
# Builds Pronto::Message objects for smells that overlap with added lines
|
|
8
|
+
# in the current PR. Uses a pre-computed relative-path index so lookup is
|
|
9
|
+
# O(1) per smell, not O(N*M) across patches x smells.
|
|
10
|
+
class MessageBuilder
|
|
11
|
+
def initialize(runner:, patches:, severity_level:, formatter: nil)
|
|
12
|
+
@runner = runner
|
|
13
|
+
@patches = patches
|
|
14
|
+
@severity_level = severity_level
|
|
15
|
+
@formatter = formatter || Formatter.detect(severity: severity_level)
|
|
16
|
+
@patch_index = build_patch_index(patches)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(modules)
|
|
20
|
+
Array(modules).flat_map { |mod| messages_for(mod) }.compact.uniq
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def messages_for(mod)
|
|
26
|
+
Array(mod.smells).filter_map do |smell|
|
|
27
|
+
patch = patch_for_smell(smell)
|
|
28
|
+
next nil unless patch
|
|
29
|
+
|
|
30
|
+
added_line = locate_added_line(patch, smell)
|
|
31
|
+
next nil unless added_line
|
|
32
|
+
|
|
33
|
+
build_message(mod, smell, added_line)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build_message(mod, smell, line)
|
|
38
|
+
Pronto::Message.new(
|
|
39
|
+
line.patch.new_file_path,
|
|
40
|
+
line,
|
|
41
|
+
@severity_level,
|
|
42
|
+
@formatter.call(mod, smell),
|
|
43
|
+
nil,
|
|
44
|
+
@runner.class
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def build_patch_index(patches)
|
|
49
|
+
Array(patches).each_with_object({}) do |patch, acc|
|
|
50
|
+
key = relative(patch.new_file_full_path)
|
|
51
|
+
acc[key] = patch
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def patch_for_smell(smell)
|
|
56
|
+
loc = Array(smell.locations).first
|
|
57
|
+
return nil unless loc.respond_to?(:pathname)
|
|
58
|
+
|
|
59
|
+
@patch_index[relative(loc.pathname)]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def locate_added_line(patch, smell)
|
|
63
|
+
smell_lines = Array(smell.locations).map(&:line)
|
|
64
|
+
return nil if smell_lines.empty?
|
|
65
|
+
|
|
66
|
+
smell_line_set = Set.new(smell_lines)
|
|
67
|
+
Array(patch.added_lines).find { |l| smell_line_set.include?(l.new_lineno) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def relative(path)
|
|
71
|
+
return '' if path.nil?
|
|
72
|
+
|
|
73
|
+
pn = path.is_a?(Pathname) ? path : Pathname.new(path.to_s)
|
|
74
|
+
return pn.to_s unless pn.absolute?
|
|
75
|
+
|
|
76
|
+
pn.relative_path_from(Pathname.pwd).to_s
|
|
77
|
+
rescue ArgumentError
|
|
78
|
+
pn.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module Pronto
|
|
6
|
+
class RubyCritic < Runner
|
|
7
|
+
# Applies user-provided filters from .rubycritic-pronto.yml to the list of
|
|
8
|
+
# analysed modules. Only implements filters that map to real RubyCritic
|
|
9
|
+
# attributes. See CHANGELOG for features dropped in 0.12.0 (reek.min_severity).
|
|
10
|
+
#
|
|
11
|
+
# Every config value is consumed defensively: a malformed section (non-Hash)
|
|
12
|
+
# or a wrong-typed value (non-numeric threshold, scalar `exclude`, junk
|
|
13
|
+
# `max_smells`) is ignored rather than raising. A raise here would be
|
|
14
|
+
# swallowed by the runner's top-level rescue and silently turn the whole
|
|
15
|
+
# quality gate into a false "all clear" — the worst failure mode for a linter.
|
|
16
|
+
class SmellFilter
|
|
17
|
+
SMELL_FILTER_KEYS = %w[flay flog reek].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(config)
|
|
20
|
+
@config = config.is_a?(Hash) ? config : {}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(modules)
|
|
24
|
+
# Fast path: an empty/nil config is semantically transparent — never
|
|
25
|
+
# mutate input modules, never call mod.smells=. This lets the filter
|
|
26
|
+
# be chained harmlessly.
|
|
27
|
+
return Array(modules) if @config.empty?
|
|
28
|
+
|
|
29
|
+
Array(modules).filter_map { |mod| filter_module(mod) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def filter_module(mod)
|
|
35
|
+
return nil unless module_within_thresholds?(mod)
|
|
36
|
+
|
|
37
|
+
filtered = filter_smells(Array(mod.smells))
|
|
38
|
+
return nil if filtered.empty? && any_smell_filter?
|
|
39
|
+
|
|
40
|
+
mod.smells = filtered if mod.respond_to?(:smells=)
|
|
41
|
+
mod
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def filter_smells(smells)
|
|
45
|
+
smells = by_analyser_exclude(smells, 'flay')
|
|
46
|
+
smells = by_analyser_exclude(smells, 'flog')
|
|
47
|
+
smells = by_analyser_max_score(smells, 'flay')
|
|
48
|
+
smells = by_analyser_max_score(smells, 'flog')
|
|
49
|
+
smells = by_reek_smell_types(smells)
|
|
50
|
+
limited(smells)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def module_within_thresholds?(mod)
|
|
54
|
+
return false if above_threshold?('complexity', mod, :complexity)
|
|
55
|
+
return false if above_threshold?('churn', mod, :churn)
|
|
56
|
+
|
|
57
|
+
true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def above_threshold?(name, mod, attr)
|
|
61
|
+
# Float(_, exception: false) returns nil for non-numeric config (e.g.
|
|
62
|
+
# `complexity.max: high`), so a bad value disables the filter instead of
|
|
63
|
+
# silently coercing to 0.0 (which would drop every module) or raising.
|
|
64
|
+
max = Float(section(name)['max'], exception: false)
|
|
65
|
+
return false if max.nil?
|
|
66
|
+
return false unless mod.respond_to?(attr)
|
|
67
|
+
|
|
68
|
+
value = mod.public_send(attr)
|
|
69
|
+
return false unless value.respond_to?(:to_f)
|
|
70
|
+
|
|
71
|
+
value.to_f > max
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def by_analyser_max_score(smells, analyser)
|
|
75
|
+
max = Float(section(analyser)['max_score'], exception: false)
|
|
76
|
+
return smells if max.nil?
|
|
77
|
+
|
|
78
|
+
smells.reject do |s|
|
|
79
|
+
smell_from_analyser?(s, analyser) &&
|
|
80
|
+
s.respond_to?(:score) && s.score.to_f > max
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def by_analyser_exclude(smells, analyser)
|
|
85
|
+
# Normalise to non-empty glob strings so a scalar (`exclude: '*.rb'`) or
|
|
86
|
+
# a list with non-string entries never reaches String#any? / fnmatch.
|
|
87
|
+
patterns = Array(section(analyser)['exclude']).map(&:to_s).reject(&:empty?)
|
|
88
|
+
return smells if patterns.empty?
|
|
89
|
+
|
|
90
|
+
smells.reject do |s|
|
|
91
|
+
smell_from_analyser?(s, analyser) && matches_any_pattern?(s, patterns)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def by_reek_smell_types(smells)
|
|
96
|
+
allowed = Array(section('reek')['smell_types']).map(&:to_s).reject(&:empty?)
|
|
97
|
+
return smells if allowed.empty?
|
|
98
|
+
|
|
99
|
+
smells.select do |s|
|
|
100
|
+
next true unless smell_from_analyser?(s, 'reek')
|
|
101
|
+
|
|
102
|
+
allowed.include?(s.type.to_s)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def limited(smells)
|
|
107
|
+
max = section('reek')['max_smells']
|
|
108
|
+
return smells if max.nil?
|
|
109
|
+
|
|
110
|
+
count = Integer(max, exception: false)
|
|
111
|
+
return smells if count.nil? || count.negative?
|
|
112
|
+
|
|
113
|
+
smells.first(count)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Returns the named config section only when it is a Hash; a scalar/array/
|
|
117
|
+
# bool value (a plausible YAML typo like `reek: false` or `complexity: 10`)
|
|
118
|
+
# is treated as "no config for that section" so Hash#dig never raises.
|
|
119
|
+
def section(name)
|
|
120
|
+
value = @config[name]
|
|
121
|
+
value.is_a?(Hash) ? value : {}
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def smell_from_analyser?(smell, analyser)
|
|
125
|
+
smell.respond_to?(:analyser) && smell.analyser.to_s == analyser
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def matches_any_pattern?(smell, patterns)
|
|
129
|
+
location = Array(smell.locations).first
|
|
130
|
+
return false unless location.respond_to?(:pathname)
|
|
131
|
+
|
|
132
|
+
relative = to_relative(location.pathname.to_s)
|
|
133
|
+
patterns.any? do |pattern|
|
|
134
|
+
File.fnmatch(pattern, relative, File::FNM_PATHNAME | File::FNM_DOTMATCH)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def to_relative(path)
|
|
139
|
+
pn = Pathname.new(path)
|
|
140
|
+
return path unless pn.absolute?
|
|
141
|
+
|
|
142
|
+
pn.relative_path_from(Pathname.pwd).to_s
|
|
143
|
+
rescue ArgumentError
|
|
144
|
+
path
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def any_smell_filter?
|
|
148
|
+
SMELL_FILTER_KEYS.any? { |k| !section(k).empty? }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pronto
|
|
4
|
+
# Version module loaded by the gemspec before Pronto::Runner is available.
|
|
5
|
+
# A separate module is used (instead of Pronto::RubyCritic::VERSION) because
|
|
6
|
+
# the main runner class inherits from Pronto::Runner, which is not yet loaded
|
|
7
|
+
# at gemspec-build time.
|
|
8
|
+
module RubyCriticVersion
|
|
9
|
+
VERSION = '0.12.0'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Pronto runner for RubyCritic. Analyses Ruby files changed in a PR and reports
|
|
4
|
+
# reek/flay/flog/complexity/churn smells via Pronto::Message — only on lines that
|
|
5
|
+
# were added or modified in the diff.
|
|
6
|
+
|
|
7
|
+
require 'pronto'
|
|
8
|
+
require 'rubycritic'
|
|
9
|
+
require 'rubycritic/analysers_runner'
|
|
10
|
+
|
|
11
|
+
require_relative 'rubycritic/version'
|
|
12
|
+
require_relative 'rubycritic/config_loader'
|
|
13
|
+
require_relative 'rubycritic/analyser'
|
|
14
|
+
require_relative 'rubycritic/smell_filter'
|
|
15
|
+
require_relative 'rubycritic/formatter'
|
|
16
|
+
require_relative 'rubycritic/message_builder'
|
|
17
|
+
|
|
18
|
+
module Pronto
|
|
19
|
+
class RubyCritic < Runner
|
|
20
|
+
VERSION = RubyCriticVersion::VERSION
|
|
21
|
+
VALID_LEVELS = %i[info warning error fatal].freeze
|
|
22
|
+
DEFAULT_LEVEL = :warning
|
|
23
|
+
CONFIG_FILE = '.rubycritic-pronto.yml'
|
|
24
|
+
SEVERITY_ENV = 'PRONTO_RUBYCRITIC_SEVERITY_LEVEL'
|
|
25
|
+
LEGACY_ENV = 'PRONTO_REEK_SEVERITY_LEVEL'
|
|
26
|
+
RAISE_ENV = 'PRONTO_RUBYCRITIC_RAISE_ERRORS'
|
|
27
|
+
DEBUG_ENV = 'PRONTO_RUBYCRITIC_DEBUG'
|
|
28
|
+
|
|
29
|
+
def run
|
|
30
|
+
patches = ruby_patches
|
|
31
|
+
return [] if patches.nil? || patches.empty?
|
|
32
|
+
|
|
33
|
+
process(patches)
|
|
34
|
+
rescue Errno::ENOENT => e
|
|
35
|
+
handle_missing_file(e)
|
|
36
|
+
[]
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
report_error(e)
|
|
39
|
+
raise if truthy_env?(RAISE_ENV)
|
|
40
|
+
|
|
41
|
+
[]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Boolean env vars are opt-in: only 1/true/yes/on enable them. A bare
|
|
47
|
+
# presence check would treat PRONTO_RUBYCRITIC_RAISE_ERRORS=0 (or =false,
|
|
48
|
+
# or an empty value) as "on", which is the opposite of what a user setting
|
|
49
|
+
# it in a CI matrix expects.
|
|
50
|
+
def truthy_env?(name)
|
|
51
|
+
%w[1 true yes on].include?(ENV.fetch(name, '').to_s.strip.downcase)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def process(patches)
|
|
55
|
+
modules = Analyser.new(file_paths).call
|
|
56
|
+
filtered = SmellFilter.new(runner_config).call(modules)
|
|
57
|
+
MessageBuilder.new(
|
|
58
|
+
runner: self,
|
|
59
|
+
patches: patches,
|
|
60
|
+
severity_level: severity_level
|
|
61
|
+
).call(filtered)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Pronto's ruby_executable? helper does File.read(path, 2) on every
|
|
65
|
+
# patched path to check for a shebang; when the working tree is missing
|
|
66
|
+
# a file that's in the diff range, it raises Errno::ENOENT. Translate
|
|
67
|
+
# that into a human-readable message that points at `git status`.
|
|
68
|
+
def handle_missing_file(error)
|
|
69
|
+
warn(
|
|
70
|
+
'pronto-rubycritic: working tree is missing a file referenced in ' \
|
|
71
|
+
"the diff: #{error.message}. Run `git status` to reconcile."
|
|
72
|
+
)
|
|
73
|
+
raise if truthy_env?(RAISE_ENV)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def file_paths
|
|
77
|
+
@file_paths ||= ruby_patches.map { |p| p.new_file_full_path.to_s }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def runner_config
|
|
81
|
+
@runner_config ||= ConfigLoader.load_runner_config(CONFIG_FILE)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def pronto_config
|
|
85
|
+
@pronto_config ||= ConfigLoader.load_pronto_config
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def severity_level
|
|
89
|
+
@severity_level ||= ConfigLoader.resolve_severity(
|
|
90
|
+
env_value: ENV[SEVERITY_ENV] || legacy_severity_env,
|
|
91
|
+
pronto_config: pronto_config,
|
|
92
|
+
valid_levels: VALID_LEVELS,
|
|
93
|
+
default: DEFAULT_LEVEL
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def legacy_severity_env
|
|
98
|
+
value = ENV.fetch(LEGACY_ENV, nil)
|
|
99
|
+
return nil if value.nil? || value.to_s.strip.empty?
|
|
100
|
+
|
|
101
|
+
warn("pronto-rubycritic: #{LEGACY_ENV} is deprecated; use #{SEVERITY_ENV}.")
|
|
102
|
+
value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def report_error(error)
|
|
106
|
+
warn("pronto-rubycritic: #{error.class}: #{error.message} " \
|
|
107
|
+
'(no messages reported for this run)')
|
|
108
|
+
return unless truthy_env?(DEBUG_ENV)
|
|
109
|
+
|
|
110
|
+
warn(Array(error.backtrace).first(10).join("\n"))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
|
4
|
+
require 'pronto/rubycritic/version'
|
|
5
|
+
|
|
6
|
+
Gem::Specification.new do |s|
|
|
7
|
+
s.name = 'pronto-rubycritic'
|
|
8
|
+
s.version = Pronto::RubyCriticVersion::VERSION
|
|
9
|
+
s.platform = Gem::Platform::RUBY
|
|
10
|
+
s.authors = ['Rishabh Singh']
|
|
11
|
+
s.email = ['rishabhs343@gmail.com']
|
|
12
|
+
s.summary = 'Pronto runner for RubyCritic code quality reports.'
|
|
13
|
+
s.description = <<~DESC
|
|
14
|
+
pronto-rubycritic integrates RubyCritic into the Pronto workflow, reporting
|
|
15
|
+
reek / flay / flog / complexity / churn smells only on lines added or
|
|
16
|
+
modified in a pull request. Supports configurable per-analyser filters and
|
|
17
|
+
GitHub/GitLab output formatting.
|
|
18
|
+
DESC
|
|
19
|
+
s.homepage = 'https://github.com/Rishabhs343/custom-pronto-gem'
|
|
20
|
+
s.licenses = ['MIT']
|
|
21
|
+
|
|
22
|
+
s.required_ruby_version = '>= 3.2.2'
|
|
23
|
+
s.required_rubygems_version = '>= 3.2.3'
|
|
24
|
+
|
|
25
|
+
s.metadata = {
|
|
26
|
+
'homepage_uri' => s.homepage,
|
|
27
|
+
'source_code_uri' => "#{s.homepage}/tree/main",
|
|
28
|
+
'bug_tracker_uri' => "#{s.homepage}/issues",
|
|
29
|
+
'changelog_uri' => "#{s.homepage}/blob/main/CHANGELOG.md",
|
|
30
|
+
'documentation_uri' => "#{s.homepage}#readme",
|
|
31
|
+
'rubygems_mfa_required' => 'true'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
s.files = Dir.chdir(__dir__) do
|
|
35
|
+
Dir.glob(%w[
|
|
36
|
+
lib/**/*.rb
|
|
37
|
+
CHANGELOG.md
|
|
38
|
+
CONTRIBUTING.md
|
|
39
|
+
LICENSE
|
|
40
|
+
README.md
|
|
41
|
+
pronto-rubycritic.gemspec
|
|
42
|
+
.rubycritic-pronto.yml
|
|
43
|
+
])
|
|
44
|
+
end
|
|
45
|
+
s.require_paths = ['lib']
|
|
46
|
+
s.extra_rdoc_files = %w[LICENSE README.md CHANGELOG.md]
|
|
47
|
+
|
|
48
|
+
# base64 was removed from Ruby's default gems in 3.4. Pronto's transitive
|
|
49
|
+
# dependency chain (via gitlab 4.20.x → base64) still requires it, so users
|
|
50
|
+
# on 3.4+ need this gem or they hit LoadError when requiring 'pronto'.
|
|
51
|
+
s.add_dependency 'base64', '~> 0.2'
|
|
52
|
+
s.add_dependency 'pronto', '>= 0.11', '< 2.0'
|
|
53
|
+
s.add_dependency 'rubycritic', '>= 4.9', '< 6.0'
|
|
54
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pronto-rubycritic
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.12.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Rishabh Singh
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-06-20 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: base64
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.2'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.2'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: pronto
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.11'
|
|
34
|
+
- - "<"
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '2.0'
|
|
37
|
+
type: :runtime
|
|
38
|
+
prerelease: false
|
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '0.11'
|
|
44
|
+
- - "<"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.0'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: rubycritic
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '4.9'
|
|
54
|
+
- - "<"
|
|
55
|
+
- !ruby/object:Gem::Version
|
|
56
|
+
version: '6.0'
|
|
57
|
+
type: :runtime
|
|
58
|
+
prerelease: false
|
|
59
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: '4.9'
|
|
64
|
+
- - "<"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '6.0'
|
|
67
|
+
description: |
|
|
68
|
+
pronto-rubycritic integrates RubyCritic into the Pronto workflow, reporting
|
|
69
|
+
reek / flay / flog / complexity / churn smells only on lines added or
|
|
70
|
+
modified in a pull request. Supports configurable per-analyser filters and
|
|
71
|
+
GitHub/GitLab output formatting.
|
|
72
|
+
email:
|
|
73
|
+
- rishabhs343@gmail.com
|
|
74
|
+
executables: []
|
|
75
|
+
extensions: []
|
|
76
|
+
extra_rdoc_files:
|
|
77
|
+
- LICENSE
|
|
78
|
+
- README.md
|
|
79
|
+
- CHANGELOG.md
|
|
80
|
+
files:
|
|
81
|
+
- ".rubycritic-pronto.yml"
|
|
82
|
+
- CHANGELOG.md
|
|
83
|
+
- CONTRIBUTING.md
|
|
84
|
+
- LICENSE
|
|
85
|
+
- README.md
|
|
86
|
+
- lib/pronto/rubycritic.rb
|
|
87
|
+
- lib/pronto/rubycritic/analyser.rb
|
|
88
|
+
- lib/pronto/rubycritic/config_loader.rb
|
|
89
|
+
- lib/pronto/rubycritic/formatter.rb
|
|
90
|
+
- lib/pronto/rubycritic/message_builder.rb
|
|
91
|
+
- lib/pronto/rubycritic/smell_filter.rb
|
|
92
|
+
- lib/pronto/rubycritic/version.rb
|
|
93
|
+
- pronto-rubycritic.gemspec
|
|
94
|
+
homepage: https://github.com/Rishabhs343/custom-pronto-gem
|
|
95
|
+
licenses:
|
|
96
|
+
- MIT
|
|
97
|
+
metadata:
|
|
98
|
+
homepage_uri: https://github.com/Rishabhs343/custom-pronto-gem
|
|
99
|
+
source_code_uri: https://github.com/Rishabhs343/custom-pronto-gem/tree/main
|
|
100
|
+
bug_tracker_uri: https://github.com/Rishabhs343/custom-pronto-gem/issues
|
|
101
|
+
changelog_uri: https://github.com/Rishabhs343/custom-pronto-gem/blob/main/CHANGELOG.md
|
|
102
|
+
documentation_uri: https://github.com/Rishabhs343/custom-pronto-gem#readme
|
|
103
|
+
rubygems_mfa_required: 'true'
|
|
104
|
+
post_install_message:
|
|
105
|
+
rdoc_options: []
|
|
106
|
+
require_paths:
|
|
107
|
+
- lib
|
|
108
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - ">="
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: 3.2.2
|
|
113
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - ">="
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: 3.2.3
|
|
118
|
+
requirements: []
|
|
119
|
+
rubygems_version: 3.0.3.1
|
|
120
|
+
signing_key:
|
|
121
|
+
specification_version: 4
|
|
122
|
+
summary: Pronto runner for RubyCritic code quality reports.
|
|
123
|
+
test_files: []
|