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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ccru
4
+ VERSION = "0.1.0"
5
+ end
data/lib/ccru.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ccru/cli"
4
+ require_relative "ccru/version"
5
+
6
+ module Ccru
7
+ class Error < StandardError; end
8
+ end
data/sig/ccru.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Ccru
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end