ccru 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/.github/workflows/main.yml +27 -0
- data/.gitignore +9 -0
- data/.rubocop.yml +13 -0
- data/CHANGELOG.md +79 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +55 -0
- data/README.md +88 -0
- data/Rakefile +8 -0
- data/bin/console +11 -0
- data/bin/setup +8 -0
- data/ccru.gemspec +39 -0
- data/exe/ccru +5 -0
- data/lib/ccru/cli.rb +84 -0
- data/lib/ccru/erb_linter.rb +83 -0
- data/lib/ccru/erb_linter_runner.rb +37 -0
- data/lib/ccru/file_handler.rb +12 -0
- data/lib/ccru/file_type_detector.rb +27 -0
- data/lib/ccru/git_diff.rb +141 -0
- data/lib/ccru/javascript_linter.rb +282 -0
- data/lib/ccru/javascript_linter_runner.rb +32 -0
- data/lib/ccru/linter_runner.rb +40 -0
- data/lib/ccru/offense_printer.rb +45 -0
- data/lib/ccru/rubocop_runner.rb +65 -0
- data/lib/ccru/usual_linter.rb +57 -0
- data/lib/ccru/version.rb +5 -0
- data/lib/ccru.rb +8 -0
- data/sig/ccru.rbs +4 -0
- metadata +98 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
require_relative "file_type_detector"
|
|
7
|
+
|
|
8
|
+
module Ccru
|
|
9
|
+
# Parse git diff to figure changed files and changed line ranges
|
|
10
|
+
class GitDiff
|
|
11
|
+
def initialize(base_ref, only_staged)
|
|
12
|
+
@base_ref = base_ref
|
|
13
|
+
@only_staged = only_staged
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns a Hash: {
|
|
17
|
+
# "path/to/file.rb" => { type: :new, lines: nil, file_type: :ruby }
|
|
18
|
+
# "path/script.js" => { type: :new, lines: nil, file_type: :javascript }
|
|
19
|
+
# "path/mod.rb" => { type: :mod, lines: Set[3,4,10], file_type: :ruby }
|
|
20
|
+
# }
|
|
21
|
+
def self.changed_files(base_ref, only_staged)
|
|
22
|
+
new(base_ref, only_staged).changed_files
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def changed_files
|
|
26
|
+
files = parse_changed_files
|
|
27
|
+
|
|
28
|
+
add_changed_lines!(files)
|
|
29
|
+
|
|
30
|
+
files
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def parse_changed_files
|
|
34
|
+
files = {}
|
|
35
|
+
|
|
36
|
+
each_status_line do |code, path|
|
|
37
|
+
next unless FileTypeDetector.supported_file?(path)
|
|
38
|
+
|
|
39
|
+
file_info = build_file_info(code, path)
|
|
40
|
+
files[path] = file_info if file_info
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
files
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def build_file_info(code, path)
|
|
47
|
+
case code
|
|
48
|
+
when "A"
|
|
49
|
+
{ type: :new, lines: nil, file_type: FileTypeDetector.file_type(path) }
|
|
50
|
+
when "M", "R"
|
|
51
|
+
{ type: :mod, lines: Set.new, file_type: FileTypeDetector.file_type(path) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def each_status_line
|
|
56
|
+
# Get all supported file types
|
|
57
|
+
extensions = FileTypeDetector::SUPPORTED_EXTENSIONS.keys.map { |ext| "*#{ext}" }
|
|
58
|
+
status_cmd = ["git", "diff", "--name-status", diff_scope, "--", *extensions].flatten
|
|
59
|
+
|
|
60
|
+
capture(status_cmd).each_line do |line|
|
|
61
|
+
next if line.strip.empty?
|
|
62
|
+
|
|
63
|
+
code, path = parse_status_line(line)
|
|
64
|
+
yield code, path if code && path
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def add_changed_lines!(files)
|
|
69
|
+
mod_paths = files.select { |_, v| v[:type] == :mod }.keys
|
|
70
|
+
return if mod_paths.empty?
|
|
71
|
+
|
|
72
|
+
diff_cmd = ["git", "diff", "--unified=0", diff_scope, "--", *mod_paths]
|
|
73
|
+
parse_diff_hunks(capture(diff_cmd), files)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_diff_hunks(diff_out, files)
|
|
77
|
+
current = nil
|
|
78
|
+
|
|
79
|
+
diff_out.each_line do |line|
|
|
80
|
+
if line.start_with?("+++ b/")
|
|
81
|
+
current = line.sub("+++ b/", "").strip
|
|
82
|
+
elsif line =~ /^@@ [^+]*\+(\d+)(?:,(\d+))? @@/
|
|
83
|
+
add_hunk_lines!(files, current, Regexp.last_match(1), Regexp.last_match(2))
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def add_hunk_lines!(files, current, start_str, count_str)
|
|
89
|
+
return unless current && files[current]
|
|
90
|
+
|
|
91
|
+
start = start_str.to_i
|
|
92
|
+
count = (count_str || "1").to_i
|
|
93
|
+
files[current][:lines].merge(start...(start + count))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def diff_scope
|
|
97
|
+
if @only_staged
|
|
98
|
+
["--staged"]
|
|
99
|
+
else
|
|
100
|
+
[merge_base, "...", "HEAD"].join
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def merge_base
|
|
105
|
+
# Prefer user-specified base or auto-detect main/master
|
|
106
|
+
base = @base_ref
|
|
107
|
+
return base if base && !base.empty?
|
|
108
|
+
|
|
109
|
+
# Try origin/main then origin/master then main/master
|
|
110
|
+
candidates = ["origin/main", "origin/master", "main", "master"]
|
|
111
|
+
|
|
112
|
+
candidates.each do |ref|
|
|
113
|
+
ok = system({ "LC_ALL" => "C" }, "git", "rev-parse", "--verify", ref, out: File::NULL, err: File::NULL)
|
|
114
|
+
return ref if ok
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Fallback to HEAD~1 if nothing else
|
|
118
|
+
"HEAD~1"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_status_line(line)
|
|
122
|
+
# Supports lines like: "A\tpath.rb", "M\tpath.rb", "R100\told.rb\tnew.rb"
|
|
123
|
+
parts = line.strip.split("\t")
|
|
124
|
+
code = parts[0]
|
|
125
|
+
|
|
126
|
+
if code && code.start_with?("R") && parts.size == 3
|
|
127
|
+
["R", parts[2]]
|
|
128
|
+
else
|
|
129
|
+
[code, parts[1]]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def capture(cmd)
|
|
134
|
+
out, err, status = Open3.capture3(*Array(cmd))
|
|
135
|
+
|
|
136
|
+
warn("ccru: failed to run #{Array(cmd).join(" ")}\n#{err}") unless status.success?
|
|
137
|
+
|
|
138
|
+
out
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "usual_linter"
|
|
4
|
+
|
|
5
|
+
require "pry"
|
|
6
|
+
|
|
7
|
+
module Ccru
|
|
8
|
+
# JavaScript linter that checks for ES6+ syntax violations and basic code quality
|
|
9
|
+
class JavaScriptLinter
|
|
10
|
+
include UsualLinter
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@offenses = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
DEFAULT_PARAMS_PATTERN = Regexp.new(
|
|
17
|
+
[
|
|
18
|
+
# function f(a = 1) {}
|
|
19
|
+
'(?:\b(?:async\s+)?function\b[^(]*\([^)]*=[^)]*\))',
|
|
20
|
+
# (a = 1) => ...
|
|
21
|
+
'(?:\b(?:async\s*)?\([^)]*=[^)]*\)\s*=>)',
|
|
22
|
+
# method m(a = 1) { ... }
|
|
23
|
+
'(?:(?:^|\{|;)\s*(?:async\s+)?(?:get|set\s+)?' \
|
|
24
|
+
'(?!if\b|for\b|while\b|switch\b|catch\b)' \
|
|
25
|
+
'[A-Za-z_$][\w$]*\s*\([^)]*=[^)]*\)\s*\{)'
|
|
26
|
+
].join("|"),
|
|
27
|
+
Regexp::MULTILINE
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# ES6+ syntax violations
|
|
31
|
+
ES6_VIOLATIONS = {
|
|
32
|
+
arrow_functions: {
|
|
33
|
+
pattern: /=>/,
|
|
34
|
+
message: "Arrow functions (ES6) are not allowed. Use function() syntax instead.",
|
|
35
|
+
cop_name: "ArrowFunctions",
|
|
36
|
+
severity: "error"
|
|
37
|
+
},
|
|
38
|
+
const_let: {
|
|
39
|
+
pattern: /\b(const|let)\b/,
|
|
40
|
+
message: "const/let (ES6) are not allowed. Use var instead.",
|
|
41
|
+
cop_name: "ConstLet",
|
|
42
|
+
severity: "error"
|
|
43
|
+
},
|
|
44
|
+
template_literals: {
|
|
45
|
+
pattern: /`[^`]*\$\{[^}]*\}[^`]*`/,
|
|
46
|
+
message: "Template literals (ES6) are not allowed. Use string concatenation instead.",
|
|
47
|
+
cop_name: "TemplateLiterals",
|
|
48
|
+
severity: "error"
|
|
49
|
+
},
|
|
50
|
+
destructuring: {
|
|
51
|
+
pattern: /\{[^}]*\s*=\s*[^}]*\}/,
|
|
52
|
+
message: "Destructuring assignment (ES6) is not allowed.",
|
|
53
|
+
cop_name: "Destructuring",
|
|
54
|
+
severity: "error"
|
|
55
|
+
},
|
|
56
|
+
spread_operator: {
|
|
57
|
+
pattern: /\.\.\./,
|
|
58
|
+
message: "Spread operator (ES6) is not allowed.",
|
|
59
|
+
cop_name: "SpreadOperator",
|
|
60
|
+
severity: "error"
|
|
61
|
+
},
|
|
62
|
+
classes: {
|
|
63
|
+
pattern: /\bclass\s+\w+/,
|
|
64
|
+
message: "ES6 classes are not allowed. Use function constructors instead.",
|
|
65
|
+
cop_name: "Classes",
|
|
66
|
+
severity: "error"
|
|
67
|
+
},
|
|
68
|
+
modules: {
|
|
69
|
+
pattern: /\b(import|export)\b/,
|
|
70
|
+
message: "ES6 modules (import/export) are not allowed. Use traditional script loading.",
|
|
71
|
+
cop_name: "Modules",
|
|
72
|
+
severity: "error"
|
|
73
|
+
},
|
|
74
|
+
default_parameters: {
|
|
75
|
+
pattern: DEFAULT_PARAMS_PATTERN,
|
|
76
|
+
message: "Default parameters (ES6) are not allowed.",
|
|
77
|
+
cop_name: "DefaultParameters",
|
|
78
|
+
severity: "error"
|
|
79
|
+
},
|
|
80
|
+
rest_parameters: {
|
|
81
|
+
pattern: /\.\.\.\w+/,
|
|
82
|
+
message: "Rest parameters (ES6) are not allowed.",
|
|
83
|
+
cop_name: "RestParameters",
|
|
84
|
+
severity: "error"
|
|
85
|
+
}
|
|
86
|
+
}.freeze
|
|
87
|
+
|
|
88
|
+
# Basic code quality rules
|
|
89
|
+
CODE_QUALITY_RULES = {
|
|
90
|
+
multiple_empty_lines: {
|
|
91
|
+
pattern: /\n\s*\n/,
|
|
92
|
+
message: "Multiple consecutive empty lines found. Use maximum 1 empty lines.",
|
|
93
|
+
cop_name: "MultipleEmptyLines",
|
|
94
|
+
severity: "warning"
|
|
95
|
+
},
|
|
96
|
+
line_too_long: {
|
|
97
|
+
pattern: /^.{121,}$/,
|
|
98
|
+
message: "Line is too long (over 120 characters). Consider breaking it into multiple lines.",
|
|
99
|
+
cop_name: "LineTooLong",
|
|
100
|
+
severity: "warning"
|
|
101
|
+
},
|
|
102
|
+
console_statements: {
|
|
103
|
+
pattern: /\bconsole\.(log|debug|info|warn|error)\s*\(/,
|
|
104
|
+
message: "Console statements should not be left in production code. Remove or use proper logging.",
|
|
105
|
+
cop_name: "ConsoleStatements",
|
|
106
|
+
severity: "warning"
|
|
107
|
+
},
|
|
108
|
+
no_inline_comment: {
|
|
109
|
+
pattern: %r{[^\s].*//.+},
|
|
110
|
+
message: "Avoid inline comments at the end of code lines.",
|
|
111
|
+
cop_name: "InlineComment",
|
|
112
|
+
severity: "warning"
|
|
113
|
+
},
|
|
114
|
+
eval_usage: {
|
|
115
|
+
pattern: /\beval\s*\(/,
|
|
116
|
+
message: "eval() is dangerous and should not be used. Use safer alternatives.",
|
|
117
|
+
cop_name: "EvalUsage",
|
|
118
|
+
severity: "error"
|
|
119
|
+
},
|
|
120
|
+
with_statement: {
|
|
121
|
+
pattern: /\bwith\s*\(/,
|
|
122
|
+
message: "with statement is deprecated and can cause scope confusion. Avoid using it.",
|
|
123
|
+
cop_name: "WithStatement",
|
|
124
|
+
severity: "error"
|
|
125
|
+
},
|
|
126
|
+
document_write: {
|
|
127
|
+
pattern: /\bdocument\.write\s*\(/,
|
|
128
|
+
message: "document.write() can cause performance issues and security risks. Use DOM manipulation instead.",
|
|
129
|
+
cop_name: "DocumentWrite",
|
|
130
|
+
severity: "warning"
|
|
131
|
+
},
|
|
132
|
+
loose_equality: {
|
|
133
|
+
pattern: /==(?!\s*null|\s*undefined)/,
|
|
134
|
+
message: "Use strict equality (===) instead of loose equality (==) to avoid type coercion issues.",
|
|
135
|
+
cop_name: "LooseEquality",
|
|
136
|
+
severity: "warning"
|
|
137
|
+
},
|
|
138
|
+
loose_inequality: {
|
|
139
|
+
pattern: /!=(?!\s*null|\s*undefined)/,
|
|
140
|
+
message: "Use strict inequality (!==) instead of loose inequality (!=) to avoid type coercion issues.",
|
|
141
|
+
cop_name: "LooseInequality",
|
|
142
|
+
severity: "warning"
|
|
143
|
+
},
|
|
144
|
+
unused_variables: {
|
|
145
|
+
pattern: /\bvar\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/,
|
|
146
|
+
message: "Variable is declared but may not be used. Consider removing if unused.",
|
|
147
|
+
cop_name: "UnusedVariables",
|
|
148
|
+
severity: "warning"
|
|
149
|
+
},
|
|
150
|
+
missing_semicolon: {
|
|
151
|
+
pattern: /[^;{}]\s*$/,
|
|
152
|
+
message: "Missing semicolon at end of statement. Add semicolon for consistency.",
|
|
153
|
+
cop_name: "MissingSemicolon",
|
|
154
|
+
severity: "warning"
|
|
155
|
+
},
|
|
156
|
+
innerhtml_usage: {
|
|
157
|
+
pattern: /\.innerHTML\s*=/,
|
|
158
|
+
message: "innerHTML can cause XSS vulnerabilities. Use textContent or proper sanitization.",
|
|
159
|
+
cop_name: "InnerhtmlUsage",
|
|
160
|
+
severity: "warning"
|
|
161
|
+
},
|
|
162
|
+
global_variables: {
|
|
163
|
+
pattern: /^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=/,
|
|
164
|
+
message: "Global variable declaration detected. Consider using var to avoid global scope pollution.",
|
|
165
|
+
cop_name: "GlobalVariables",
|
|
166
|
+
severity: "warning"
|
|
167
|
+
}
|
|
168
|
+
}.freeze
|
|
169
|
+
|
|
170
|
+
def lint_file(content)
|
|
171
|
+
check_final_newline(content)
|
|
172
|
+
check_js_conventions(content)
|
|
173
|
+
check_trailing_whitespace(content)
|
|
174
|
+
|
|
175
|
+
@offenses
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def check_js_conventions(content)
|
|
179
|
+
@current_code = content.lines
|
|
180
|
+
|
|
181
|
+
@current_code.each_with_index do |line_content, index|
|
|
182
|
+
next if line_content.strip.empty?
|
|
183
|
+
|
|
184
|
+
line_number = index + 1
|
|
185
|
+
|
|
186
|
+
next if commment?(line_content, line_number)
|
|
187
|
+
|
|
188
|
+
check_line_conventions(line_content, line_number)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# rubocop:disable Metrics
|
|
193
|
+
def check_line_conventions(line_content, line_number)
|
|
194
|
+
ES6_VIOLATIONS.merge(CODE_QUALITY_RULES).each do |rule_name, rule|
|
|
195
|
+
next if line_content.match(rule[:pattern]).nil?
|
|
196
|
+
next if rule_name == :missing_semicolon && no_need_semicolon?(line_content)
|
|
197
|
+
next if rule_name == :unused_variables && used_variable?(line_content, line_number)
|
|
198
|
+
next if rule_name == :loose_equality && line_content.include?("!==")
|
|
199
|
+
|
|
200
|
+
add_offense(rule_name, rule, line_content, line_number)
|
|
201
|
+
break
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
# rubocop:enable Metrics
|
|
205
|
+
|
|
206
|
+
def used_variable?(line_content, line_number)
|
|
207
|
+
# Extract variable name from var declaration
|
|
208
|
+
match = line_content.match(/\bvar\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/)
|
|
209
|
+
return false unless match
|
|
210
|
+
|
|
211
|
+
variable_name = match[1]
|
|
212
|
+
|
|
213
|
+
count_occurrences(variable_name, line_number) > 0
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def count_occurrences(variable_name, line_number)
|
|
217
|
+
# Count occurrences of the variable name
|
|
218
|
+
# Exclude the declaration line itself
|
|
219
|
+
total_occurrences = 0
|
|
220
|
+
|
|
221
|
+
@current_code.each_with_index do |code_line, index|
|
|
222
|
+
# Skip the declaration line
|
|
223
|
+
next if index <= line_number - 1
|
|
224
|
+
next if code_line.match(/(?<![A-Za-z0-9_])#{Regexp.escape(variable_name)}(?![A-Za-z0-9_])/).nil?
|
|
225
|
+
|
|
226
|
+
total_occurrences += 1
|
|
227
|
+
break
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
total_occurrences
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# rubocop:disable Metrics
|
|
234
|
+
def no_need_semicolon?(line_content)
|
|
235
|
+
line = line_content.strip
|
|
236
|
+
return true if line.empty?
|
|
237
|
+
|
|
238
|
+
# Already have ; no need to check
|
|
239
|
+
return true if line.end_with?(";") || line.end_with?(",")
|
|
240
|
+
|
|
241
|
+
# End with { or } (block, function, class, etc.)
|
|
242
|
+
return true if line.end_with?("{") || line == "}"
|
|
243
|
+
|
|
244
|
+
# Open or close array/object literal, grouping
|
|
245
|
+
return true if line.match(/[\[\(]\s*$/) || line.match(/^[\])]\s*$/)
|
|
246
|
+
|
|
247
|
+
# Control statements don't need ;
|
|
248
|
+
return true if line.match(/^(if|else|for|while|switch|try|catch|finally)\b/)
|
|
249
|
+
|
|
250
|
+
# function / class declaration
|
|
251
|
+
return true if line.match(/^function\b/) || line.match(/^class\b/)
|
|
252
|
+
|
|
253
|
+
# Control flow keywords (return, break, continue, throw)
|
|
254
|
+
# Actually, it may be necessary to have ; if the expression behind starts with ( or [
|
|
255
|
+
# but JS ASI will handle it, I consider it unnecessary
|
|
256
|
+
return true if line.match(/^(return|break|continue|throw)\b/)
|
|
257
|
+
|
|
258
|
+
false
|
|
259
|
+
end
|
|
260
|
+
# rubocop:enable Metrics
|
|
261
|
+
|
|
262
|
+
def commment?(line_content, line_number)
|
|
263
|
+
line = line_content.strip
|
|
264
|
+
return true if line.start_with?("//") || line.start_with?("/*") || line.end_with?("*/")
|
|
265
|
+
|
|
266
|
+
in_block_comment?(line_number)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def in_block_comment?(line_number)
|
|
270
|
+
in_block = false
|
|
271
|
+
|
|
272
|
+
@current_code.each_with_index do |code_line, idx|
|
|
273
|
+
in_block = true if code_line.strip.start_with?("/*")
|
|
274
|
+
return true if in_block && idx == line_number - 1
|
|
275
|
+
|
|
276
|
+
in_block = false if code_line.strip.end_with?("*/")
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
false
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "file_handler"
|
|
4
|
+
require_relative "javascript_linter"
|
|
5
|
+
|
|
6
|
+
module Ccru
|
|
7
|
+
# JavaScript-specific linter methods
|
|
8
|
+
module JavaScriptLinterRunner
|
|
9
|
+
include FileHandler
|
|
10
|
+
|
|
11
|
+
def run_javascript_linter_file(path)
|
|
12
|
+
content = safe_read_file(path)
|
|
13
|
+
return 0 unless content
|
|
14
|
+
|
|
15
|
+
js_linter = JavaScriptLinter.new
|
|
16
|
+
offenses = js_linter.lint_file(content)
|
|
17
|
+
return 0 if offenses.empty?
|
|
18
|
+
|
|
19
|
+
print_offenses(path, offenses)
|
|
20
|
+
1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run_javascript_linter_filtered(path, meta)
|
|
24
|
+
content = safe_read_file(path)
|
|
25
|
+
return 0 unless content && meta[:lines].any?
|
|
26
|
+
|
|
27
|
+
linter = JavaScriptLinter.new
|
|
28
|
+
result = linter.lint_code(content, path, meta[:lines])
|
|
29
|
+
result[:status]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rubocop_runner"
|
|
4
|
+
require_relative "erb_linter_runner"
|
|
5
|
+
require_relative "javascript_linter_runner"
|
|
6
|
+
|
|
7
|
+
module Ccru
|
|
8
|
+
# Handles execution of different linters (RuboCop for Ruby, custom for JavaScript, ERB parsing)
|
|
9
|
+
module LinterRunner
|
|
10
|
+
include RuboCopRunner
|
|
11
|
+
include ErbLinterRunner
|
|
12
|
+
include JavaScriptLinterRunner
|
|
13
|
+
|
|
14
|
+
def run_linter_file(path, meta)
|
|
15
|
+
case meta[:file_type]
|
|
16
|
+
when :ruby
|
|
17
|
+
run_rubocop_file(path)
|
|
18
|
+
when :javascript
|
|
19
|
+
run_javascript_linter_file(path)
|
|
20
|
+
when :erb
|
|
21
|
+
run_erb_linter_file(path)
|
|
22
|
+
else
|
|
23
|
+
0
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run_linter_filtered(path, meta)
|
|
28
|
+
case meta[:file_type]
|
|
29
|
+
when :ruby
|
|
30
|
+
run_rubocop_filtered(path, meta)
|
|
31
|
+
when :javascript
|
|
32
|
+
run_javascript_linter_filtered(path, meta)
|
|
33
|
+
when :erb
|
|
34
|
+
run_erb_linter_filtered(path, meta)
|
|
35
|
+
else
|
|
36
|
+
0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ccru
|
|
4
|
+
# Handles printing of RuboCop offenses
|
|
5
|
+
module OffensePrinter
|
|
6
|
+
def print_offenses(path, offenses)
|
|
7
|
+
offenses.each { |offense| print_single_offense(path, offense) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def print_single_offense(path, offense)
|
|
11
|
+
location = offense["location"]
|
|
12
|
+
print_offense_summary(path, offense, location)
|
|
13
|
+
print_offense_code(path, location)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def print_offense_summary(path, offense, location)
|
|
17
|
+
line = location["line"]
|
|
18
|
+
column = location["column"]
|
|
19
|
+
severity_flag = offense["severity"][0].upcase
|
|
20
|
+
cop_name = offense["cop_name"]
|
|
21
|
+
message = offense["message"]
|
|
22
|
+
puts "#{path}:#{line}:#{column}: #{severity_flag}: #{cop_name}: #{message}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def print_offense_code(path, location)
|
|
26
|
+
return unless File.exist?(path)
|
|
27
|
+
|
|
28
|
+
code_lines = File.readlines(path)
|
|
29
|
+
line_content = code_lines[location["line"] - 1]
|
|
30
|
+
return unless line_content
|
|
31
|
+
|
|
32
|
+
puts line_content.rstrip
|
|
33
|
+
puts_target_flags(line_content, location)
|
|
34
|
+
rescue StandardError
|
|
35
|
+
# Skip if cannot read file
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def puts_target_flags(line_content, location)
|
|
39
|
+
return puts if location["column"] == 1
|
|
40
|
+
|
|
41
|
+
target = "#{" " * (location["column"] - 1)}#{"^" * location["length"]}"
|
|
42
|
+
puts target[0...line_content.length]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
require_relative "file_handler"
|
|
7
|
+
require_relative "offense_printer"
|
|
8
|
+
|
|
9
|
+
module Ccru
|
|
10
|
+
# RuboCop-specific linter methods
|
|
11
|
+
module RuboCopRunner
|
|
12
|
+
include FileHandler
|
|
13
|
+
include OffensePrinter
|
|
14
|
+
|
|
15
|
+
def run_rubocop_file(path)
|
|
16
|
+
content = safe_read_file(path)
|
|
17
|
+
return 0 unless content
|
|
18
|
+
|
|
19
|
+
result = run_rubocop_and_parse(path, content)
|
|
20
|
+
return result unless result.is_a?(Array)
|
|
21
|
+
|
|
22
|
+
print_offenses(path, result)
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run_rubocop_filtered(path, meta)
|
|
27
|
+
content = safe_read_file(path)
|
|
28
|
+
return 0 unless content && meta[:lines].any?
|
|
29
|
+
|
|
30
|
+
result = run_rubocop_and_parse(path, content)
|
|
31
|
+
return result unless result.is_a?(Array)
|
|
32
|
+
|
|
33
|
+
offenses = filter_offenses_by_lines(result, meta[:lines])
|
|
34
|
+
return 0 if offenses.empty?
|
|
35
|
+
|
|
36
|
+
print_offenses(path, offenses)
|
|
37
|
+
1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run_rubocop_and_parse(path, content)
|
|
41
|
+
out, = run_rubocop_json(path, content)
|
|
42
|
+
data = safe_parse_json(path, out)
|
|
43
|
+
return 1 unless data
|
|
44
|
+
|
|
45
|
+
files = data["files"] || []
|
|
46
|
+
(files.first && files.first["offenses"]) || []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def run_rubocop_json(path, content)
|
|
50
|
+
cmd = ["rubocop", "--format", "json", "--force-exclusion", "--stdin", path]
|
|
51
|
+
Open3.capture3(*cmd, stdin_data: content)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def safe_parse_json(path, out)
|
|
55
|
+
JSON.parse(out)
|
|
56
|
+
rescue StandardError
|
|
57
|
+
warn("ccru: failed to parse rubocop json for #{path}")
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def filter_offenses_by_lines(offenses, changed_lines)
|
|
62
|
+
offenses.select { |offense| changed_lines.include?(offense["location"]["line"]) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ccru
|
|
4
|
+
# Handles execution of different linters (RuboCop for Ruby, custom for JavaScript, ERB parsing)
|
|
5
|
+
module UsualLinter
|
|
6
|
+
USUAL_VIOLATIONS = {
|
|
7
|
+
# Trailing whitespace
|
|
8
|
+
trailing_whitespace: {
|
|
9
|
+
pattern: /\s+$/,
|
|
10
|
+
message: "Remove trailing whitespace",
|
|
11
|
+
cop_name: "TrailingWhitespace",
|
|
12
|
+
severity: "warning"
|
|
13
|
+
},
|
|
14
|
+
# Missing final newline
|
|
15
|
+
missing_final_newline: {
|
|
16
|
+
pattern: /[^\n]$/,
|
|
17
|
+
message: "Add final newline at end of file",
|
|
18
|
+
cop_name: "MissingFinalNewline",
|
|
19
|
+
severity: "warning"
|
|
20
|
+
}
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
def check_trailing_whitespace(content)
|
|
24
|
+
content.lines.each_with_index do |line_content, index|
|
|
25
|
+
line_number = index + 1
|
|
26
|
+
check_line_trailing_whitespace(line_content, line_number)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_final_newline(content)
|
|
31
|
+
return if content.empty? || content.end_with?("\n")
|
|
32
|
+
|
|
33
|
+
rule_name = :missing_final_newline
|
|
34
|
+
rule = USUAL_VIOLATIONS[rule_name]
|
|
35
|
+
add_offense(rule_name, rule, "End of file", content.lines.count)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def check_line_trailing_whitespace(line_content, line_number)
|
|
39
|
+
rule_name = :trailing_whitespace
|
|
40
|
+
rule = USUAL_VIOLATIONS[rule_name]
|
|
41
|
+
return if line_content.match(rule[:pattern]).nil? || line_content.gsub("\n", "")[-1] != " "
|
|
42
|
+
|
|
43
|
+
add_offense(rule_name, rule, line_content, line_number)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def add_offense(rule_name, violation, line_content, line_number)
|
|
47
|
+
@offenses << {
|
|
48
|
+
"rule" => rule_name,
|
|
49
|
+
"line_content" => line_content,
|
|
50
|
+
"message" => violation[:message],
|
|
51
|
+
"severity" => violation[:severity],
|
|
52
|
+
"cop_name" => violation[:cop_name],
|
|
53
|
+
"location" => { "line" => line_number, "column" => 1 }
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
data/lib/ccru/version.rb
ADDED
data/lib/ccru.rb
ADDED
data/sig/ccru.rbs
ADDED