lumin 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/CHANGELOG.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +208 -0
- data/RULES.md +46 -0
- data/Rakefile +8 -0
- data/benchmark/README.md +33 -0
- data/benchmark/lumin_bench.rb +65 -0
- data/benchmark/rubocop.yml +11 -0
- data/exe/lumin +6 -0
- data/lib/lumin/autocorrector.rb +125 -0
- data/lib/lumin/cache.rb +202 -0
- data/lib/lumin/cache_manifest.rb +189 -0
- data/lib/lumin/cli.rb +139 -0
- data/lib/lumin/cli_formatters.rb +15 -0
- data/lib/lumin/config/loader.rb +52 -0
- data/lib/lumin/config/validator.rb +110 -0
- data/lib/lumin/config.rb +99 -0
- data/lib/lumin/error.rb +5 -0
- data/lib/lumin/formatters/github.rb +26 -0
- data/lib/lumin/formatters/json.rb +32 -0
- data/lib/lumin/formatters/text.rb +33 -0
- data/lib/lumin/offense.rb +58 -0
- data/lib/lumin/parallel_executor.rb +188 -0
- data/lib/lumin/registry.rb +92 -0
- data/lib/lumin/rule.rb +97 -0
- data/lib/lumin/rules/lint/no_method_missing.rb +18 -0
- data/lib/lumin/rules/style/frozen_string_literal.rb +55 -0
- data/lib/lumin/rules/style/line_length.rb +109 -0
- data/lib/lumin/runner.rb +189 -0
- data/lib/lumin/source_snapshot.rb +30 -0
- data/lib/lumin/version.rb +5 -0
- data/lib/lumin/worker.rb +103 -0
- data/lib/lumin.rb +40 -0
- metadata +90 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Lumin
|
|
7
|
+
class Config
|
|
8
|
+
class Validator
|
|
9
|
+
def initialize(data, registry)
|
|
10
|
+
@data = data
|
|
11
|
+
@registry = registry
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def validate!
|
|
15
|
+
validate_top_level
|
|
16
|
+
validate_rules
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :data, :registry
|
|
22
|
+
|
|
23
|
+
def validate_top_level
|
|
24
|
+
unknown = data.keys - TOP_LEVEL_KEYS
|
|
25
|
+
raise ConfigError, "unknown configuration key: #{unknown.first}" unless unknown.empty?
|
|
26
|
+
|
|
27
|
+
raise ConfigError, "target_ruby must be a string" unless data["target_ruby"].is_a?(String)
|
|
28
|
+
validate_target_ruby(data["target_ruby"])
|
|
29
|
+
validate_string_array("include", data["include"])
|
|
30
|
+
validate_string_array("exclude", data["exclude"])
|
|
31
|
+
raise ConfigError, "rules must be a mapping" unless data["rules"].is_a?(Hash)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def validate_rules
|
|
35
|
+
data["rules"].each do |name, settings|
|
|
36
|
+
rule_class = registry.all[name]
|
|
37
|
+
raise ConfigError, unknown_rule_message(name) unless rule_class
|
|
38
|
+
raise ConfigError, "configuration for #{name} must be a mapping" unless settings.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
validate_rule_settings(name, settings, rule_class)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def validate_rule_settings(name, settings, rule_class)
|
|
45
|
+
allowed = RULE_CONTROL_KEYS + rule_class.default_config.keys.map(&:to_s)
|
|
46
|
+
unknown = settings.keys - allowed
|
|
47
|
+
raise ConfigError, "unknown setting #{unknown.first} for #{name}" unless unknown.empty?
|
|
48
|
+
|
|
49
|
+
settings.each do |key, value|
|
|
50
|
+
validate_leaf(name, value)
|
|
51
|
+
validate_control(name, key, value)
|
|
52
|
+
validate_parameter_type(name, key, value, rule_class)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
message = rule_class.configuration_error(settings.transform_keys(&:to_sym))
|
|
56
|
+
raise ConfigError, "#{name} #{message}" if message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def validate_target_ruby(value)
|
|
60
|
+
version = Gem::Version.new(value)
|
|
61
|
+
raise ConfigError, "target_ruby must be 3.3 or newer" if version < Gem::Version.new("3.3")
|
|
62
|
+
|
|
63
|
+
prism_version = value.match?(/\A\d+\.\d+\z/) ? "#{value}.0" : value
|
|
64
|
+
Prism.parse("", version: prism_version)
|
|
65
|
+
rescue ArgumentError
|
|
66
|
+
raise ConfigError, "unsupported target_ruby: #{value.inspect}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def validate_string_array(name, value)
|
|
70
|
+
valid = value.is_a?(Array) && value.all? { |entry| entry.is_a?(String) }
|
|
71
|
+
raise ConfigError, "#{name} must be an array of strings" unless valid
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_leaf(name, value)
|
|
75
|
+
valid = value.nil? || value.is_a?(String) || value.is_a?(Numeric) ||
|
|
76
|
+
value == true || value == false ||
|
|
77
|
+
(value.is_a?(Array) && value.all? { |entry| !entry.is_a?(Hash) && !entry.is_a?(Array) })
|
|
78
|
+
raise ConfigError, "#{name} settings must contain only scalar values or arrays" unless valid
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def validate_control(name, key, value)
|
|
82
|
+
return unless RULE_CONTROL_KEYS.include?(key)
|
|
83
|
+
return if value == true || value == false
|
|
84
|
+
|
|
85
|
+
raise ConfigError, "#{name} #{key} must be true or false"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_parameter_type(name, key, value, rule_class)
|
|
89
|
+
default = rule_class.default_config[key.to_sym]
|
|
90
|
+
return if default.nil? || RULE_CONTROL_KEYS.include?(key) || compatible_type?(value, default)
|
|
91
|
+
|
|
92
|
+
raise ConfigError, "#{name} #{key} must be a #{default.class}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def compatible_type?(value, default)
|
|
96
|
+
return value == true || value == false if default == true || default == false
|
|
97
|
+
return value.is_a?(Integer) if default.is_a?(Integer)
|
|
98
|
+
return value.is_a?(Numeric) if default.is_a?(Numeric)
|
|
99
|
+
|
|
100
|
+
value.is_a?(default.class)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def unknown_rule_message(name)
|
|
104
|
+
suggestion = DidYouMean::SpellChecker.new(dictionary: registry.all.keys).correct(name).first
|
|
105
|
+
suffix = suggestion ? "; did you mean #{suggestion}?" : ""
|
|
106
|
+
"unknown rule: #{name}#{suffix}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
data/lib/lumin/config.rb
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "error"
|
|
6
|
+
require_relative "registry"
|
|
7
|
+
|
|
8
|
+
module Lumin
|
|
9
|
+
class Config
|
|
10
|
+
class ConfigError < Error; end
|
|
11
|
+
|
|
12
|
+
DEFAULTS = {
|
|
13
|
+
"target_ruby" => "3.3",
|
|
14
|
+
"include" => ["**/*.rb"],
|
|
15
|
+
"exclude" => ["vendor/**/*"],
|
|
16
|
+
"rules" => {}
|
|
17
|
+
}.freeze
|
|
18
|
+
TOP_LEVEL_KEYS = (DEFAULTS.keys + ["inherit_from"]).freeze
|
|
19
|
+
RULE_CONTROL_KEYS = %w[enabled autocorrect].freeze
|
|
20
|
+
|
|
21
|
+
attr_reader :data, :digest, :path
|
|
22
|
+
|
|
23
|
+
def self.load(path = ".lumin.yml", registry: Registry)
|
|
24
|
+
data = File.file?(path) ? Loader.new.load_file(File.expand_path(path)) : DEFAULTS
|
|
25
|
+
new(data, path: File.expand_path(path), registry: registry)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.from_hash(data = nil, registry: Registry, **values)
|
|
29
|
+
data ||= values
|
|
30
|
+
new(deep_merge(DEFAULTS, stringify_keys(data)), path: nil, registry: registry)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.deep_merge(base, override)
|
|
34
|
+
base.merge(override) do |_key, left, right|
|
|
35
|
+
left.is_a?(Hash) && right.is_a?(Hash) ? deep_merge(left, right) : right
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.stringify_keys(value)
|
|
40
|
+
case value
|
|
41
|
+
when Hash then value.to_h { |key, child| [key.to_s, stringify_keys(child)] }
|
|
42
|
+
when Array then value.map { |child| stringify_keys(child) }
|
|
43
|
+
else value
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def initialize(data, path:, registry: Registry)
|
|
48
|
+
@data = self.class.deep_merge(DEFAULTS, self.class.stringify_keys(data))
|
|
49
|
+
@path = path
|
|
50
|
+
Validator.new(@data, registry).validate!
|
|
51
|
+
@data = deep_freeze(@data)
|
|
52
|
+
@digest = Digest::SHA256.hexdigest(JSON.generate(canonical(@data)))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def target_ruby
|
|
56
|
+
data.fetch("target_ruby")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def include_patterns
|
|
60
|
+
data.fetch("include")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def exclude_patterns
|
|
64
|
+
data.fetch("exclude")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def rule_config(name)
|
|
68
|
+
data.fetch("rules").fetch(name, {}).transform_keys(&:to_sym)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def enabled_rules(registry = Registry)
|
|
72
|
+
registry.all.filter_map do |name, rule_class|
|
|
73
|
+
settings = rule_config(name)
|
|
74
|
+
[name, rule_class, settings] unless settings[:enabled] == false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def deep_freeze(value)
|
|
81
|
+
case value
|
|
82
|
+
when Hash then value.each { |key, child| key.freeze; deep_freeze(child) }
|
|
83
|
+
when Array then value.each { |child| deep_freeze(child) }
|
|
84
|
+
end
|
|
85
|
+
value.freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def canonical(value)
|
|
89
|
+
case value
|
|
90
|
+
when Hash then value.keys.sort.to_h { |key| [key, canonical(value[key])] }
|
|
91
|
+
when Array then value.map { |child| canonical(child) }
|
|
92
|
+
else value
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
require_relative "config/loader"
|
|
99
|
+
require_relative "config/validator"
|
data/lib/lumin/error.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lumin
|
|
4
|
+
module Formatters
|
|
5
|
+
class Github
|
|
6
|
+
def format(offenses)
|
|
7
|
+
offenses.map do |offense|
|
|
8
|
+
location = offense.location
|
|
9
|
+
properties = "file=#{escape_property(offense.path)},line=#{location.start_line}," \
|
|
10
|
+
"col=#{location.start_column + 1},title=#{escape_property(offense.rule_name)}"
|
|
11
|
+
"::error #{properties}::#{escape_data(offense.message)}"
|
|
12
|
+
end.join("\n")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def escape_property(value)
|
|
18
|
+
escape_data(value).gsub(":", "%3A").gsub(",", "%2C")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def escape_data(value)
|
|
22
|
+
value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Lumin
|
|
6
|
+
module Formatters
|
|
7
|
+
class Json
|
|
8
|
+
def format(offenses)
|
|
9
|
+
JSON.generate(offenses.map { |offense| serialize(offense) })
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def serialize(offense)
|
|
15
|
+
location = offense.location
|
|
16
|
+
{
|
|
17
|
+
path: offense.path,
|
|
18
|
+
rule: offense.rule_name,
|
|
19
|
+
message: offense.message,
|
|
20
|
+
correctable: offense.correctable?,
|
|
21
|
+
safe: offense.safe,
|
|
22
|
+
location: {
|
|
23
|
+
start_line: location.start_line,
|
|
24
|
+
start_column: location.start_column,
|
|
25
|
+
end_line: location.end_line,
|
|
26
|
+
end_column: location.end_column
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lumin
|
|
4
|
+
module Formatters
|
|
5
|
+
class Text
|
|
6
|
+
def format(offenses)
|
|
7
|
+
@lines_by_path = {}
|
|
8
|
+
offenses.map { |offense| format_offense(offense) }.join("\n")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def format_offense(offense)
|
|
14
|
+
location = offense.location
|
|
15
|
+
marker = offense.correctable? ? " [correctable]" : ""
|
|
16
|
+
heading = "#{offense.path}:#{location.start_line}:#{location.start_column + 1}: " \
|
|
17
|
+
"#{offense.rule_name}: #{offense.message}#{marker}"
|
|
18
|
+
source_line = offense.source_line || source_line_for(offense)
|
|
19
|
+
return heading unless source_line
|
|
20
|
+
|
|
21
|
+
width = [location.end_column - location.start_column, 1].max
|
|
22
|
+
"#{heading}\n #{source_line}\n #{' ' * location.start_column}#{'^' * width}"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def source_line_for(offense)
|
|
26
|
+
lines = @lines_by_path[offense.path] ||= File.readlines(offense.path, chomp: true)
|
|
27
|
+
lines[offense.location.start_line - 1]
|
|
28
|
+
rescue SystemCallError, EncodingError
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "astel"
|
|
4
|
+
|
|
5
|
+
module Lumin
|
|
6
|
+
class Offense
|
|
7
|
+
attr_reader :path, :location, :rule_name, :message, :safe, :source_line, :correction
|
|
8
|
+
|
|
9
|
+
def initialize(path:, location:, rule_name:, message:, correctable: false, safe: true, source_line: nil,
|
|
10
|
+
correction: nil)
|
|
11
|
+
@path = path.to_s.freeze
|
|
12
|
+
@location = Astel::Location.from(location)
|
|
13
|
+
@rule_name = rule_name.to_s.freeze
|
|
14
|
+
@message = message.to_s.freeze
|
|
15
|
+
@correctable = correctable
|
|
16
|
+
@safe = safe
|
|
17
|
+
@source_line = source_line&.freeze
|
|
18
|
+
@correction = correction
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def correctable?
|
|
22
|
+
@correctable
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_path(path)
|
|
26
|
+
self.class.new(**to_h.merge(path: path))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{
|
|
31
|
+
path: path,
|
|
32
|
+
location: location,
|
|
33
|
+
rule_name: rule_name,
|
|
34
|
+
message: message,
|
|
35
|
+
correctable: correctable?,
|
|
36
|
+
safe: safe,
|
|
37
|
+
source_line: source_line
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ==(other)
|
|
42
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
43
|
+
end
|
|
44
|
+
alias eql? ==
|
|
45
|
+
|
|
46
|
+
def hash
|
|
47
|
+
to_h.hash
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def marshal_dump
|
|
51
|
+
to_h
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def marshal_load(attributes)
|
|
55
|
+
initialize(**attributes)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thread"
|
|
4
|
+
require_relative "worker"
|
|
5
|
+
|
|
6
|
+
module Lumin
|
|
7
|
+
class ParallelExecutor
|
|
8
|
+
Assignment = Data.define(:worker, :paths)
|
|
9
|
+
|
|
10
|
+
def initialize(config:, registry:, count:, worker_class: Worker)
|
|
11
|
+
@config = config
|
|
12
|
+
@registry = registry
|
|
13
|
+
@count = count
|
|
14
|
+
@worker_class = worker_class
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(files, **options)
|
|
18
|
+
if Process.respond_to?(:fork)
|
|
19
|
+
process_parallel(files, options)
|
|
20
|
+
else
|
|
21
|
+
thread_parallel(files, options)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def process_parallel(files, options)
|
|
28
|
+
workers = []
|
|
29
|
+
queue = build_jobs(files)
|
|
30
|
+
active = {}
|
|
31
|
+
@count.times do
|
|
32
|
+
worker = spawn_worker(options)
|
|
33
|
+
workers << worker
|
|
34
|
+
assign(worker, queue.shift, active)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
collect_process_results(active, queue, workers, options)
|
|
38
|
+
ensure
|
|
39
|
+
workers&.each { |worker| shutdown_worker(worker) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def collect_process_results(active, queue, workers, options)
|
|
43
|
+
results = []
|
|
44
|
+
until active.empty?
|
|
45
|
+
ready, = IO.select(active.keys)
|
|
46
|
+
ready.each do |reader|
|
|
47
|
+
assignment = active.fetch(reader)
|
|
48
|
+
batch = receive_result(reader, assignment)
|
|
49
|
+
results.concat(batch)
|
|
50
|
+
if worker_failure?(batch)
|
|
51
|
+
retire_worker(assignment.worker, reader, active)
|
|
52
|
+
start_replacement(queue, active, workers, options) unless queue.empty?
|
|
53
|
+
elsif queue.empty?
|
|
54
|
+
stop_worker(assignment.worker, reader, active)
|
|
55
|
+
else
|
|
56
|
+
assign(assignment.worker, queue.shift, active)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
results
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def spawn_worker(options)
|
|
64
|
+
job_reader, job_writer = IO.pipe
|
|
65
|
+
result_reader, result_writer = IO.pipe
|
|
66
|
+
worker = @worker_class.new(config: @config, registry: @registry)
|
|
67
|
+
pid = fork do
|
|
68
|
+
job_writer.close
|
|
69
|
+
result_reader.close
|
|
70
|
+
run_child(worker, job_reader, result_writer, options)
|
|
71
|
+
end
|
|
72
|
+
job_reader.close
|
|
73
|
+
result_writer.close
|
|
74
|
+
{ pid: pid, job_writer: job_writer, result_reader: result_reader }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def run_child(worker, job_reader, result_writer, options)
|
|
78
|
+
loop do
|
|
79
|
+
paths = Marshal.load(job_reader)
|
|
80
|
+
break if paths.nil?
|
|
81
|
+
|
|
82
|
+
Marshal.dump(paths.map { |path| worker.call(path, **options) }, result_writer)
|
|
83
|
+
result_writer.flush
|
|
84
|
+
end
|
|
85
|
+
exit! 0
|
|
86
|
+
rescue EOFError
|
|
87
|
+
exit! 0
|
|
88
|
+
rescue StandardError
|
|
89
|
+
exit! 1
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def send_job(worker, path)
|
|
93
|
+
Marshal.dump(path, worker.fetch(:job_writer))
|
|
94
|
+
worker.fetch(:job_writer).flush
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def assign(worker, paths, active)
|
|
98
|
+
send_job(worker, paths)
|
|
99
|
+
active[worker.fetch(:result_reader)] = Assignment.new(worker: worker, paths: paths)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def receive_result(reader, assignment)
|
|
103
|
+
Marshal.load(reader)
|
|
104
|
+
rescue EOFError, TypeError => error
|
|
105
|
+
assignment.paths.map do |path|
|
|
106
|
+
Worker::FileResult.new(
|
|
107
|
+
path: path,
|
|
108
|
+
offenses: [],
|
|
109
|
+
corrected: false,
|
|
110
|
+
diff: nil,
|
|
111
|
+
warnings: [],
|
|
112
|
+
error: "worker process ended before returning a result (#{error.class})",
|
|
113
|
+
content_digest: nil,
|
|
114
|
+
fingerprint: nil
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def start_replacement(queue, active, workers, options)
|
|
120
|
+
worker = spawn_worker(options)
|
|
121
|
+
workers << worker
|
|
122
|
+
assign(worker, queue.shift, active)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def worker_failure?(batch)
|
|
126
|
+
batch.any? && batch.all? { |result| result.error&.start_with?("worker process") }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_jobs(files)
|
|
130
|
+
batch_size = [[files.length / (@count * 64), 1].max, 8].min
|
|
131
|
+
files.each_slice(batch_size).to_a
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def stop_worker(worker, reader, active)
|
|
135
|
+
send_job(worker, nil)
|
|
136
|
+
worker.fetch(:job_writer).close
|
|
137
|
+
reader.close
|
|
138
|
+
wait_for(worker)
|
|
139
|
+
active.delete(reader)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def retire_worker(worker, reader, active)
|
|
143
|
+
worker.fetch(:job_writer).close unless worker.fetch(:job_writer).closed?
|
|
144
|
+
reader.close unless reader.closed?
|
|
145
|
+
wait_for(worker)
|
|
146
|
+
active.delete(reader)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def shutdown_worker(worker)
|
|
150
|
+
return if worker[:waited]
|
|
151
|
+
|
|
152
|
+
send_job(worker, nil) unless worker[:job_writer]&.closed?
|
|
153
|
+
rescue Errno::EPIPE, IOError
|
|
154
|
+
nil
|
|
155
|
+
ensure
|
|
156
|
+
worker[:job_writer]&.close unless worker[:job_writer]&.closed?
|
|
157
|
+
worker[:result_reader]&.close unless worker[:result_reader]&.closed?
|
|
158
|
+
wait_for(worker)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def wait_for(worker)
|
|
162
|
+
return if worker[:waited]
|
|
163
|
+
|
|
164
|
+
Process.wait(worker.fetch(:pid))
|
|
165
|
+
worker[:waited] = true
|
|
166
|
+
rescue Errno::ECHILD
|
|
167
|
+
worker[:waited] = true
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def thread_parallel(files, options)
|
|
171
|
+
queue = Queue.new
|
|
172
|
+
build_jobs(files).each { |paths| queue << paths }
|
|
173
|
+
@count.times { queue << nil }
|
|
174
|
+
results = Queue.new
|
|
175
|
+
threads = Array.new(@count) do
|
|
176
|
+
Thread.new do
|
|
177
|
+
worker = @worker_class.new(config: @config, registry: @registry)
|
|
178
|
+
while (paths = queue.pop)
|
|
179
|
+
results << paths.map { |path| worker.call(path, **options) }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
threads.each(&:join)
|
|
184
|
+
batches = Array.new(build_jobs(files).length) { results.pop }
|
|
185
|
+
batches.flatten
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
require "thread"
|
|
5
|
+
require_relative "error"
|
|
6
|
+
|
|
7
|
+
module Lumin
|
|
8
|
+
module Registry
|
|
9
|
+
BUILTIN_RULE_NAMES = %w[
|
|
10
|
+
Style/FrozenStringLiteral
|
|
11
|
+
Style/LineLength
|
|
12
|
+
Lint/NoMethodMissing
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def register(rule_class)
|
|
17
|
+
classes << rule_class unless classes.include?(rule_class)
|
|
18
|
+
rule_class
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def all
|
|
22
|
+
load_builtin_rules
|
|
23
|
+
ordered_classes.to_h { |rule_class| [name_for(rule_class), rule_class] }.freeze
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fetch(name)
|
|
27
|
+
all.fetch(name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def name_for(rule_class)
|
|
31
|
+
name = rule_class.name
|
|
32
|
+
raise Error, "anonymous rule classes cannot be registered" unless name
|
|
33
|
+
|
|
34
|
+
name.sub(/\ALumin::Rules::/, "").gsub("::", "/")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def register_namespace(namespace, visited = Set.new)
|
|
38
|
+
return if visited.include?(namespace.object_id)
|
|
39
|
+
|
|
40
|
+
visited << namespace.object_id
|
|
41
|
+
namespace.constants(false).each do |constant|
|
|
42
|
+
value = namespace.const_get(constant)
|
|
43
|
+
if value.is_a?(Class) && defined?(Lumin::Rule) && value < Lumin::Rule
|
|
44
|
+
register(value)
|
|
45
|
+
elsif value.is_a?(Module)
|
|
46
|
+
register_namespace(value, visited)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def reset!
|
|
52
|
+
load_builtin_rules
|
|
53
|
+
builtin_mutex.synchronize do
|
|
54
|
+
@classes = []
|
|
55
|
+
@builtins_loaded = true
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def classes
|
|
62
|
+
@classes ||= []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def ordered_classes
|
|
66
|
+
builtins, extensions = classes.partition do |rule_class|
|
|
67
|
+
BUILTIN_RULE_NAMES.include?(name_for(rule_class))
|
|
68
|
+
end
|
|
69
|
+
builtins.sort_by! { |rule_class| BUILTIN_RULE_NAMES.index(name_for(rule_class)) }
|
|
70
|
+
builtins.concat(extensions)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def load_builtin_rules
|
|
74
|
+
return if @builtins_loaded
|
|
75
|
+
|
|
76
|
+
builtin_mutex.synchronize do
|
|
77
|
+
return if @builtins_loaded
|
|
78
|
+
|
|
79
|
+
require_relative "rule"
|
|
80
|
+
require_relative "rules/style/frozen_string_literal"
|
|
81
|
+
require_relative "rules/style/line_length"
|
|
82
|
+
require_relative "rules/lint/no_method_missing"
|
|
83
|
+
@builtins_loaded = true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def builtin_mutex
|
|
88
|
+
@builtin_mutex ||= Mutex.new
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|