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.
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require_relative "cache_manifest"
6
+ require_relative "registry"
7
+ require_relative "source_snapshot"
8
+ require_relative "version"
9
+
10
+ module Lumin
11
+ class Cache
12
+ PathEntry = CacheManifest::PathEntry
13
+
14
+ MANIFEST = "manifest".freeze
15
+ LOCK_FILE = "manifest.lock".freeze
16
+ MANIFEST_VERSION = CacheManifest::VERSION
17
+ LEGACY_DIRECTORY_PATTERN = /\A[0-9a-f]{2}\z/
18
+ MANIFEST_TEMPORARY_PATTERN = /\A#{Regexp.escape(MANIFEST)}\.\d+\.tmp\z/
19
+
20
+ attr_reader :directory
21
+
22
+ def self.default_directory
23
+ base = ENV["XDG_CACHE_HOME"]
24
+ base = File.join(Dir.home, ".cache") if base.nil? || base.empty?
25
+ File.join(base, "lumin")
26
+ end
27
+
28
+ def initialize(config:, registry: Registry, directory: self.class.default_directory)
29
+ @config = config
30
+ @registry = registry
31
+ @directory = File.expand_path(directory)
32
+ @environment_digest = build_environment_digest
33
+ @manifest = nil
34
+ @pending_paths = {}
35
+ @pending_offenses = {}
36
+ end
37
+
38
+ def fetch(path, source = nil)
39
+ expanded_path = File.expand_path(path)
40
+ entry = @pending_paths.fetch(expanded_path) { manifest.paths[expanded_path] }
41
+ if valid_entry?(entry, path)
42
+ cached = cached_offenses(entry.key)
43
+ return rebase(cached, path) if cached
44
+ end
45
+ return unless source
46
+
47
+ cached = cached_offenses(key(source))
48
+ rebase(cached, path) if cached
49
+ end
50
+
51
+ def put(path:, offenses:, source: nil, content_digest: nil, fingerprint: nil)
52
+ current_fingerprint = SourceSnapshot.fingerprint(path)
53
+ return false if fingerprint && fingerprint != current_fingerprint
54
+
55
+ cache_key = key_for_digest(content_digest || digest_source(source))
56
+ @pending_offenses[cache_key] = offenses
57
+ @pending_paths[File.expand_path(path)] = PathEntry.new(
58
+ fingerprint: current_fingerprint,
59
+ environment_digest: @environment_digest,
60
+ key: cache_key
61
+ )
62
+ offenses
63
+ end
64
+
65
+ def flush
66
+ return false if @pending_paths.empty?
67
+
68
+ with_lock do
69
+ merged = merge_pending(CacheManifest.load(manifest_path))
70
+ compacted = compact(merged)
71
+ atomic_write(manifest_path) { |file| file.write(CacheManifest.serialize(compacted)) }
72
+ remove_legacy_entries
73
+ @manifest = compacted
74
+ @pending_paths.clear
75
+ @pending_offenses.clear
76
+ end
77
+ true
78
+ end
79
+
80
+ def clear
81
+ with_lock do
82
+ FileUtils.rm_f(manifest_path)
83
+ remove_legacy_entries
84
+ @manifest = CacheManifest.empty
85
+ @pending_paths.clear
86
+ @pending_offenses.clear
87
+ end
88
+ true
89
+ end
90
+
91
+ def key(source)
92
+ key_for_digest(Digest::SHA256.hexdigest(source))
93
+ end
94
+
95
+ private
96
+
97
+ def key_for_digest(content_digest)
98
+ Digest::SHA256.hexdigest(content_digest + @environment_digest)
99
+ end
100
+
101
+ def digest_source(source)
102
+ raise ArgumentError, "source or content_digest is required" unless source
103
+
104
+ Digest::SHA256.hexdigest(source)
105
+ end
106
+
107
+ def build_environment_digest
108
+ enabled = @config.enabled_rules(@registry).map(&:first).sort.join("\0")
109
+ Digest::SHA256.hexdigest([@config.digest, VERSION, enabled].join("\0"))
110
+ end
111
+
112
+ def manifest
113
+ @manifest ||= CacheManifest.load(manifest_path)
114
+ end
115
+
116
+ def merge_pending(latest)
117
+ paths = latest.paths.dup
118
+ @pending_paths.each do |path, entry|
119
+ paths[path] = entry if current_fingerprint?(path, entry.fingerprint)
120
+ end
121
+ CacheManifest::Contents.new(paths: paths, offenses: latest.offenses.merge(@pending_offenses))
122
+ end
123
+
124
+ def compact(data)
125
+ paths = data.paths.select do |path, entry|
126
+ data.offenses.key?(entry.key) && current_fingerprint?(path, entry.fingerprint)
127
+ end
128
+ referenced_keys = paths.each_value.to_h { |entry| [entry.key, true] }
129
+ offenses = data.offenses.select { |cache_key, _entries| referenced_keys.key?(cache_key) }
130
+ CacheManifest::Contents.new(paths: paths, offenses: offenses)
131
+ end
132
+
133
+ def valid_entry?(entry, path)
134
+ entry.is_a?(PathEntry) &&
135
+ entry.environment_digest == @environment_digest &&
136
+ current_fingerprint?(path, entry.fingerprint)
137
+ end
138
+
139
+ def current_fingerprint?(path, fingerprint)
140
+ SourceSnapshot.fingerprint(path) == fingerprint
141
+ rescue ArgumentError, SystemCallError
142
+ false
143
+ end
144
+
145
+ def cached_offenses(cache_key)
146
+ @pending_offenses.fetch(cache_key) { manifest.offenses[cache_key] }
147
+ end
148
+
149
+ def rebase(offenses, path)
150
+ offenses.map { |offense| offense.with_path(path) }
151
+ end
152
+
153
+ def with_lock
154
+ FileUtils.mkdir_p(directory)
155
+ File.open(lock_path, File::RDWR | File::CREAT, 0o600) do |lock|
156
+ lock.flock(File::LOCK_EX)
157
+ yield
158
+ ensure
159
+ lock.flock(File::LOCK_UN)
160
+ end
161
+ end
162
+
163
+ def atomic_write(target)
164
+ temporary = "#{target}.#{Process.pid}.tmp"
165
+ File.open(temporary, "wb", 0o600) { |file| yield file }
166
+ File.rename(temporary, target)
167
+ ensure
168
+ FileUtils.rm_f(temporary) if defined?(temporary) && temporary
169
+ end
170
+
171
+ def remove_legacy_entries
172
+ Dir.children(directory).each do |child|
173
+ path = File.join(directory, child)
174
+ status = File.lstat(path)
175
+ if child.match?(LEGACY_DIRECTORY_PATTERN) && status.directory?
176
+ remove_legacy_content_files(path, child)
177
+ elsif child.match?(MANIFEST_TEMPORARY_PATTERN) && status.file?
178
+ FileUtils.rm_f(path)
179
+ end
180
+ end
181
+ rescue SystemCallError
182
+ nil
183
+ end
184
+
185
+ def remove_legacy_content_files(directory_path, prefix)
186
+ pattern = /\A#{prefix}[0-9a-f]{62}(?:\.\d+\.tmp)?\z/
187
+ Dir.children(directory_path).each do |entry|
188
+ path = File.join(directory_path, entry)
189
+ FileUtils.rm_f(path) if entry.match?(pattern) && File.lstat(path).file?
190
+ end
191
+ Dir.rmdir(directory_path) if Dir.empty?(directory_path)
192
+ end
193
+
194
+ def manifest_path
195
+ File.join(directory, MANIFEST)
196
+ end
197
+
198
+ def lock_path
199
+ File.join(directory, LOCK_FILE)
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "offense"
5
+
6
+ module Lumin
7
+ class CacheManifest
8
+ PathEntry = Data.define(:fingerprint, :environment_digest, :key)
9
+ Contents = Data.define(:paths, :offenses)
10
+
11
+ VERSION = 2
12
+ DIGEST_PATTERN = /\A[0-9a-f]{64}\z/
13
+ LOCATION_KEYS = %w[start_offset end_offset start_line start_column end_line end_column].freeze
14
+
15
+ class << self
16
+ def load(path)
17
+ return empty unless File.file?(path)
18
+
19
+ payload = File.open(path, "rb") do |file|
20
+ JSON.parse(file.read, create_additions: false, max_nesting: 20)
21
+ end
22
+ return empty unless payload.is_a?(Hash) && payload["version"] == VERSION
23
+
24
+ decode(payload)
25
+ rescue StandardError
26
+ empty
27
+ end
28
+
29
+ def serialize(data)
30
+ JSON.generate(
31
+ "version" => VERSION,
32
+ "paths" => data.paths.map { |path, entry| encode_path_entry(path, entry) },
33
+ "offenses" => data.offenses.transform_values do |entries|
34
+ entries.map { |entry| encode_offense(entry) }
35
+ end
36
+ )
37
+ end
38
+
39
+ def empty
40
+ Contents.new(paths: {}, offenses: {})
41
+ end
42
+
43
+ private
44
+
45
+ def encode_path_entry(path, entry)
46
+ {
47
+ "path" => encode_string(path),
48
+ "fingerprint" => entry.fingerprint,
49
+ "environment_digest" => entry.environment_digest,
50
+ "key" => entry.key
51
+ }
52
+ end
53
+
54
+ def encode_offense(offense)
55
+ {
56
+ "location" => offense.location.to_h.transform_keys(&:to_s),
57
+ "rule_name" => encode_string(offense.rule_name),
58
+ "message" => encode_string(offense.message),
59
+ "correctable" => offense.correctable?,
60
+ "safe" => offense.safe,
61
+ "source_line" => offense.source_line && encode_string(offense.source_line)
62
+ }
63
+ end
64
+
65
+ def decode(payload)
66
+ raw_paths = payload["paths"].is_a?(Array) ? payload["paths"] : []
67
+ raw_offenses = payload["offenses"].is_a?(Hash) ? payload["offenses"] : {}
68
+ Contents.new(paths: decode_paths(raw_paths), offenses: decode_offenses(raw_offenses))
69
+ end
70
+
71
+ def decode_paths(raw_paths)
72
+ raw_paths.each_with_object({}) do |value, paths|
73
+ next unless value.is_a?(Hash)
74
+
75
+ path = decode_string(value["path"])
76
+ next unless path && !path.b.include?("\0".b)
77
+
78
+ entry = decode_path_entry(value)
79
+ paths[path] = entry if entry
80
+ end
81
+ end
82
+
83
+ def decode_path_entry(value)
84
+ return unless value.is_a?(Hash)
85
+
86
+ fingerprint = value["fingerprint"]
87
+ environment_digest = value["environment_digest"]
88
+ cache_key = value["key"]
89
+ return unless valid_fingerprint?(fingerprint)
90
+ return unless valid_digest?(environment_digest) && valid_digest?(cache_key)
91
+
92
+ PathEntry.new(fingerprint: fingerprint.freeze, environment_digest: environment_digest, key: cache_key)
93
+ end
94
+
95
+ def decode_offenses(raw_offenses)
96
+ raw_offenses.each_with_object({}) do |(cache_key, values), offenses|
97
+ next unless valid_digest?(cache_key) && values.is_a?(Array)
98
+
99
+ entries = values.map { |value| decode_offense(value) }
100
+ offenses[cache_key] = entries if entries.none?(&:nil?)
101
+ end
102
+ end
103
+
104
+ def decode_offense(value)
105
+ return unless value.is_a?(Hash)
106
+
107
+ location = decode_location(value["location"])
108
+ rule_name = decode_string(value["rule_name"])
109
+ message = decode_string(value["message"])
110
+ correctable = value["correctable"]
111
+ safe = value["safe"]
112
+ source_line = value["source_line"] && decode_string(value["source_line"])
113
+ return unless location && rule_name && message
114
+ return unless boolean?(correctable) && boolean?(safe)
115
+ return if value["source_line"] && !source_line
116
+
117
+ Offense.new(
118
+ path: "",
119
+ location: location,
120
+ rule_name: rule_name,
121
+ message: message,
122
+ correctable: correctable,
123
+ safe: safe,
124
+ source_line: source_line
125
+ )
126
+ end
127
+
128
+ def decode_location(value)
129
+ return unless value.is_a?(Hash)
130
+
131
+ attributes = LOCATION_KEYS.to_h { |key| [key.to_sym, value[key]] }
132
+ return unless attributes.values.all? { |part| part.is_a?(Integer) }
133
+ return unless valid_location?(attributes)
134
+
135
+ Astel::Location.new(**attributes)
136
+ end
137
+
138
+ def valid_location?(location)
139
+ return false if location[:start_offset].negative? || location[:start_offset] > location[:end_offset]
140
+ return false if location[:start_line] < 1 || location[:end_line] < location[:start_line]
141
+ return false if location[:start_column].negative? || location[:end_column].negative?
142
+
143
+ location[:end_line] != location[:start_line] || location[:end_column] >= location[:start_column]
144
+ end
145
+
146
+ def valid_fingerprint?(value)
147
+ return false unless value.is_a?(Array) && value.length == 6
148
+ return false unless value.all? { |part| part.is_a?(Integer) }
149
+
150
+ size, _mtime, mtime_nsec, _ctime, ctime_nsec, inode = value
151
+ size >= 0 && inode >= 0 && [mtime_nsec, ctime_nsec].all? { |part| part.between?(0, 999_999_999) }
152
+ end
153
+
154
+ def valid_digest?(value)
155
+ valid_json_string?(value) && value.match?(DIGEST_PATTERN)
156
+ end
157
+
158
+ def boolean?(value)
159
+ value == true || value == false
160
+ end
161
+
162
+ def encode_string(value)
163
+ {
164
+ "bytes" => [value.b].pack("m0"),
165
+ "encoding" => value.encoding.name
166
+ }
167
+ end
168
+
169
+ def decode_string(value)
170
+ return unless value.is_a?(Hash)
171
+
172
+ bytes = value["bytes"]
173
+ encoding_name = value["encoding"]
174
+ return unless valid_json_string?(bytes) && valid_json_string?(encoding_name)
175
+
176
+ encoding = Encoding.find(encoding_name)
177
+ return unless encoding.is_a?(Encoding)
178
+
179
+ bytes.unpack1("m0").force_encoding(encoding)
180
+ rescue ArgumentError, TypeError
181
+ nil
182
+ end
183
+
184
+ def valid_json_string?(value)
185
+ value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding?
186
+ end
187
+ end
188
+ end
189
+ end
data/lib/lumin/cli.rb ADDED
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Lumin
6
+ class CLI
7
+ FORMATTER_FEATURES = {
8
+ "text" => "formatters/text",
9
+ "json" => "formatters/json",
10
+ "github" => "formatters/github"
11
+ }.freeze
12
+ autoload :FORMATTERS, File.expand_path("cli_formatters", __dir__)
13
+
14
+ def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr)
15
+ new(stdout: stdout, stderr: stderr).run(argv)
16
+ end
17
+
18
+ def initialize(stdout:, stderr:)
19
+ @stdout = stdout
20
+ @stderr = stderr
21
+ end
22
+
23
+ def run(argv)
24
+ options = default_options
25
+ arguments = argv.dup
26
+ parser = option_parser(options)
27
+ parser.parse!(arguments)
28
+ if options[:help]
29
+ @stdout.puts(options[:help])
30
+ return 0
31
+ end
32
+ return print_version if options[:version]
33
+ raise OptionParser::InvalidOption, "--dry-run requires --fix" if options[:dry_run] && !options[:fix]
34
+
35
+ load_runtime
36
+ execute(options, arguments)
37
+ rescue OptionParser::ParseError, ArgumentError => error
38
+ @stderr.puts("lumin: #{error.message}")
39
+ 2
40
+ rescue StandardError => error
41
+ @stderr.puts("lumin: internal error: #{error.class}: #{error.message}")
42
+ 2
43
+ end
44
+
45
+ private
46
+
47
+ def execute(options, arguments)
48
+ config = Config.load(options[:config])
49
+ runner = Runner.new(
50
+ config: config,
51
+ parallel: options[:parallel],
52
+ cache: options[:cache],
53
+ cache_dir: options[:cache_dir]
54
+ )
55
+ if options[:clear_cache]
56
+ runner.clear_cache
57
+ return 0
58
+ end
59
+
60
+ result = runner.run(arguments, fix: options[:fix], unsafe: options[:unsafe], dry_run: options[:dry_run])
61
+ result.diffs.each { |diff| @stdout.puts(diff) }
62
+ unless options[:dry_run]
63
+ formatted = formatter(options[:format]).new.format(result.offenses)
64
+ @stdout.puts(formatted) unless formatted.empty?
65
+ end
66
+ result.warnings.each { |warning| @stderr.puts("warning: #{warning}") }
67
+ result.errors.each { |error| @stderr.puts(error) }
68
+ return 2 unless result.errors.empty?
69
+
70
+ result.offenses.empty? ? 0 : 1
71
+ rescue Config::ConfigError => error
72
+ @stderr.puts("lumin: #{error.message}")
73
+ 2
74
+ end
75
+
76
+ def default_options
77
+ {
78
+ config: ".lumin.yml",
79
+ format: "text",
80
+ parallel: nil,
81
+ cache: true,
82
+ cache_dir: nil,
83
+ clear_cache: false,
84
+ fix: false,
85
+ unsafe: false,
86
+ dry_run: false,
87
+ help: nil,
88
+ version: false
89
+ }
90
+ end
91
+
92
+ def option_parser(options)
93
+ OptionParser.new do |parser|
94
+ parser.banner = "Usage: lumin [options] [paths...]"
95
+ parser.on("-c", "--config PATH", "configuration file") { |value| options[:config] = value }
96
+ parser.on("-f", "--format NAME", FORMATTER_FEATURES.keys, "output format") do |value|
97
+ options[:format] = value
98
+ end
99
+ parser.on("--parallel[=N]", Integer, "number of worker processes") do |value|
100
+ options[:parallel] = value || processor_count
101
+ end
102
+ parser.on("--no-parallel", "disable parallel execution") { options[:parallel] = false }
103
+ parser.on("--[no-]cache", "enable or disable result cache") { |value| options[:cache] = value }
104
+ parser.on("--cache-dir PATH", "cache directory") { |value| options[:cache_dir] = value }
105
+ parser.on("--clear-cache", "remove cached results") { options[:clear_cache] = true }
106
+ parser.on("--fix", "apply safe corrections") { options[:fix] = true }
107
+ parser.on("--fix-unsafe", "apply safe and unsafe corrections") do
108
+ options[:fix] = true
109
+ options[:unsafe] = true
110
+ end
111
+ parser.on("--dry-run", "show corrections as a unified diff") { options[:dry_run] = true }
112
+ parser.on("-v", "--version", "print the version") { options[:version] = true }
113
+ parser.on("-h", "--help", "show this help") do
114
+ options[:help] = parser.to_s
115
+ end
116
+ end
117
+ end
118
+
119
+ def print_version
120
+ require_relative "version"
121
+ @stdout.puts(VERSION)
122
+ 0
123
+ end
124
+
125
+ def processor_count
126
+ require "etc"
127
+ Etc.nprocessors
128
+ end
129
+
130
+ def load_runtime
131
+ require_relative "../lumin"
132
+ end
133
+
134
+ def formatter(name)
135
+ require_relative FORMATTER_FEATURES.fetch(name)
136
+ Formatters.const_get(name.capitalize, false)
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "formatters/text"
4
+ require_relative "formatters/json"
5
+ require_relative "formatters/github"
6
+
7
+ module Lumin
8
+ class CLI
9
+ FORMATTERS = {
10
+ "text" => Formatters::Text,
11
+ "json" => Formatters::Json,
12
+ "github" => Formatters::Github
13
+ }.freeze
14
+ end
15
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Lumin
6
+ class Config
7
+ class Loader
8
+ def initialize
9
+ @loading = []
10
+ end
11
+
12
+ def load_file(path)
13
+ expanded = File.expand_path(path)
14
+ if @loading.include?(expanded)
15
+ raise ConfigError, "configuration inheritance cycle: #{expanded}"
16
+ end
17
+
18
+ @loading << expanded
19
+ raw = YAML.safe_load_file(expanded, permitted_classes: [], permitted_symbols: [], aliases: false) || {}
20
+ raise ConfigError, "configuration root must be a mapping" unless raw.is_a?(Hash)
21
+
22
+ raw = Config.stringify_keys(raw)
23
+ inherited = Array(raw.delete("inherit_from")).reduce({}) do |merged, reference|
24
+ Config.deep_merge(merged, load_file(resolve(reference, File.dirname(expanded))))
25
+ end
26
+ Config.deep_merge(inherited, raw)
27
+ rescue Psych::Exception => error
28
+ raise ConfigError, "cannot load #{expanded}: #{error.message}"
29
+ ensure
30
+ @loading.delete(expanded) if expanded
31
+ end
32
+
33
+ private
34
+
35
+ def resolve(reference, directory)
36
+ raise ConfigError, "inherit_from entries must be strings" unless reference.is_a?(String)
37
+
38
+ candidate = File.expand_path(reference, directory)
39
+ return candidate if File.file?(candidate)
40
+
41
+ specification = Gem::Specification.find_by_name(reference)
42
+ gem_config = [File.join(specification.full_gem_path, ".lumin.yml"),
43
+ File.join(specification.full_gem_path, "config", "lumin.yml")].find { |path| File.file?(path) }
44
+ return gem_config if gem_config
45
+
46
+ raise ConfigError, "#{reference} does not provide a Lumin configuration"
47
+ rescue Gem::MissingSpecError
48
+ raise ConfigError, "cannot resolve inherited configuration: #{reference}"
49
+ end
50
+ end
51
+ end
52
+ end