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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c0e71927fa0a39ebcef64e2921aea21c7898ddbec64088640b69e0e8d6e88025
4
+ data.tar.gz: e7a8b7a424ed0fbc43a3de35dbc2bf8ebac9c5b3158e337fa88723d61cf03649
5
+ SHA512:
6
+ metadata.gz: 59100309f1000e3577f0961dbec8e92deca533848b25956011ff54db828a754958de7bd43a762062475825a019d201a5a4d71a9d95bd7395be16bc8ef9c4425d
7
+ data.tar.gz: b134624d1dcf071c6f2296c2bc91a68e65c841f3e6861c904efab09e3b4656c8b5a2391b8859e6f0150918ca5cc148d9e40201204d531c167eca89ba519a6e64
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here following Keep a Changelog, and the
4
+ project follows Semantic Versioning.
5
+
6
+ ## [Unreleased]
7
+
8
+ ## [0.1.0] - 2026-07-16
9
+
10
+ - Initial release.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # Lumin
2
+
3
+ Lumin is a fast, Prism-native linter for Ruby. It parses and walks each file
4
+ once, regardless of the number of enabled rules, and is designed for fast
5
+ feedback on both local checkouts and CI.
6
+
7
+ ## Features
8
+
9
+ - A single Prism parse and AST walk per file
10
+ - Process-based parallelism with a thread fallback on platforms without `fork`
11
+ - Content-addressed caching that accounts for source, configuration, version,
12
+ and enabled rules
13
+ - Safe autocorrection, optional unsafe corrections, and dry-run diffs
14
+ - Text, JSON, and GitHub Actions output formats
15
+ - Strict configuration validation and shareable configuration through files or
16
+ gems
17
+ - A plugin API for adding project-specific rules
18
+
19
+ ## Requirements
20
+
21
+ Lumin requires Ruby 3.3 or newer.
22
+
23
+ ## Installation
24
+
25
+ Add Lumin to your `Gemfile`:
26
+
27
+ ```ruby
28
+ gem "lumin", require: false
29
+ ```
30
+
31
+ Then install the bundle:
32
+
33
+ ```sh
34
+ bundle install
35
+ ```
36
+
37
+ For use outside a bundle, install the gem directly:
38
+
39
+ ```sh
40
+ gem install lumin
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ Run Lumin without paths to check the files selected by the configuration:
46
+
47
+ ```sh
48
+ bundle exec lumin
49
+ ```
50
+
51
+ Pass one or more files or directories to limit the check:
52
+
53
+ ```sh
54
+ bundle exec lumin app/ lib/ spec/example_spec.rb
55
+ ```
56
+
57
+ Apply safe corrections with `--fix`:
58
+
59
+ ```sh
60
+ bundle exec lumin --fix
61
+ ```
62
+
63
+ Preview corrections as a unified diff without changing files:
64
+
65
+ ```sh
66
+ bundle exec lumin --fix --dry-run
67
+ ```
68
+
69
+ Use `--fix-unsafe` to include corrections declared unsafe by a rule.
70
+
71
+ ### Output formats
72
+
73
+ The default output is human-readable text. JSON and GitHub Actions annotations
74
+ are also available:
75
+
76
+ ```sh
77
+ bundle exec lumin --format json
78
+ bundle exec lumin --format github
79
+ ```
80
+
81
+ ### Exit status
82
+
83
+ | Status | Meaning |
84
+ |---:|---|
85
+ | `0` | No offenses remain |
86
+ | `1` | One or more offenses were found |
87
+ | `2` | Configuration or execution failed |
88
+
89
+ ## Configuration
90
+
91
+ Lumin works without a configuration file. To customize it, create
92
+ `.lumin.yml` in the project root:
93
+
94
+ ```yaml
95
+ target_ruby: "3.3"
96
+
97
+ include:
98
+ - "app/**/*.rb"
99
+ - "lib/**/*.rb"
100
+ - "spec/**/*.rb"
101
+
102
+ exclude:
103
+ - "vendor/**/*"
104
+ - "tmp/**/*"
105
+
106
+ rules:
107
+ Style/FrozenStringLiteral:
108
+ enabled: true
109
+ autocorrect: true
110
+ Style/LineLength:
111
+ max: 120
112
+ Lint/NoMethodMissing:
113
+ enabled: false
114
+ ```
115
+
116
+ `target_ruby` controls the Ruby version passed to Prism when parsing source.
117
+ The default is Ruby 3.3. Unknown top-level keys, rule names, and rule settings
118
+ are rejected before linting begins.
119
+
120
+ Any rule can be disabled with `enabled: false`. For a correctable rule,
121
+ `autocorrect: false` reports offenses without applying its corrections.
122
+
123
+ Use a different configuration file with `--config`:
124
+
125
+ ```sh
126
+ bundle exec lumin --config config/lumin.yml
127
+ ```
128
+
129
+ ### Inheriting configuration
130
+
131
+ `inherit_from` accepts a local path or the name of a gem that provides either
132
+ `.lumin.yml` or `config/lumin.yml`:
133
+
134
+ ```yaml
135
+ inherit_from:
136
+ - config/lumin_base.yml
137
+ - lumin-config-example
138
+
139
+ rules:
140
+ Style/LineLength:
141
+ max: 100
142
+ ```
143
+
144
+ Inherited mappings are deeply merged, and the current file takes precedence.
145
+
146
+ ## Built-in rules
147
+
148
+ All built-in rules are enabled by default.
149
+
150
+ | Rule | Description | Configuration | Autocorrect |
151
+ |---|---|---|---|
152
+ | `Style/FrozenStringLiteral` | Requires a `frozen_string_literal` magic comment | — | Safe |
153
+ | `Style/LineLength` | Reports lines longer than the configured limit | `max` (default: `120`) | No |
154
+ | `Lint/NoMethodMissing` | Reports definitions of `method_missing` | — | No |
155
+
156
+ See [RULES.md](RULES.md) for the rule and plugin API.
157
+
158
+ ## Command-line options
159
+
160
+ | Option | Description |
161
+ |---|---|
162
+ | `-c`, `--config PATH` | Use a specific configuration file |
163
+ | `-f`, `--format NAME` | Select `text`, `json`, or `github` output |
164
+ | `--parallel[=N]` | Enable parallel execution with an optional worker count |
165
+ | `--no-parallel` | Force serial execution |
166
+ | `--[no-]cache` | Enable or disable the result cache |
167
+ | `--cache-dir PATH` | Use a specific cache directory |
168
+ | `--clear-cache` | Remove cached results and exit |
169
+ | `--fix` | Apply safe corrections |
170
+ | `--fix-unsafe` | Apply both safe and unsafe corrections |
171
+ | `--dry-run` | Print corrections as a diff; requires `--fix` |
172
+ | `-v`, `--version` | Print the Lumin version |
173
+ | `-h`, `--help` | Print command-line help |
174
+
175
+ By default, Lumin estimates the workload from the total input size. Small runs
176
+ stay serial to avoid process startup overhead, while larger runs add workers
177
+ gradually up to the available CPU count. Use `--parallel=N` to choose an exact
178
+ maximum or `--no-parallel` to force serial execution. The result cache is stored
179
+ under `$XDG_CACHE_HOME/lumin` or `~/.cache/lumin`.
180
+
181
+ ## Development
182
+
183
+ Install dependencies and run the test suite:
184
+
185
+ ```sh
186
+ bundle install
187
+ bundle exec rake
188
+ ```
189
+
190
+ Verify the packaged gem with:
191
+
192
+ ```sh
193
+ bundle exec ruby script/package_smoke.rb
194
+ ```
195
+
196
+ Benchmark instructions and recorded results are available in
197
+ [benchmark/README.md](benchmark/README.md).
198
+
199
+ ## Contributing
200
+
201
+ Bug reports and pull requests are welcome. Please include tests for behavior
202
+ changes and update [CHANGELOG.md](CHANGELOG.md) when the change is
203
+ user-visible.
204
+
205
+ ## License
206
+
207
+ Lumin is available as open source under the terms of the
208
+ [MIT License](LICENSE.txt).
data/RULES.md ADDED
@@ -0,0 +1,46 @@
1
+ # Writing Lumin rules
2
+
3
+ Rules inherit from `Lumin::Rule`. A rule may subscribe to Prism node types,
4
+ run once for each source, or use both hooks. The dispatcher combines all node
5
+ subscriptions into one AST walk.
6
+
7
+ ```ruby
8
+ module Lumin
9
+ module Rules
10
+ module Lint
11
+ class NoEval < Lumin::Rule
12
+ MSG = "avoid eval"
13
+
14
+ on :call_node do |node|
15
+ add_offense(node.message_loc, message: MSG) if node.name == :eval
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ ```
22
+
23
+ Configuration defaults declare all supported rule-specific keys:
24
+
25
+ ```ruby
26
+ default_config max: 120
27
+ safe false
28
+ ```
29
+
30
+ Pass a correction block to make an offense correctable. The block is evaluated
31
+ only during `--fix` and receives an `Astel::Rewriter`:
32
+
33
+ ```ruby
34
+ add_offense(node.location, message: MSG) do |rewriter|
35
+ rewriter.replace(node.location, "replacement")
36
+ end
37
+ ```
38
+
39
+ Rules are registered when their class inherits from `Lumin::Rule`. A plugin gem
40
+ should require `lumin`, define its rules under its own module, then call
41
+ `Lumin.register_plugin(MyPlugin::Rules)`. Plugin rule classes must still have
42
+ stable, named constants so configuration names and cache keys remain stable.
43
+
44
+ Rule tests can include the helper style used by Lumin itself:
45
+ `expect_offense(code, rule: "Lint/NoEval")` and
46
+ `expect_correction(code, corrected, rule: "Lint/NoEval")`.
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -0,0 +1,33 @@
1
+ # Benchmarks
2
+
3
+ Run the persistent benchmark against a substantial Ruby checkout. For example,
4
+ after cloning Rails outside this repository:
5
+
6
+ ```sh
7
+ bundle exec ruby benchmark/lumin_bench.rb ../rails
8
+ ```
9
+
10
+ The script measures 1, 4, and 8 workers, followed by cold- and warm-cache
11
+ runs. Set `RUBOCOP=1` to compare the same files against the two equivalent
12
+ RuboCop rules. Set `VERIFY=1` to enforce the v0.1 performance gates: at least
13
+ 5x serial throughput with eight available cores, a warm-cache duration below
14
+ one tenth of the cold run, and at least 10x RuboCop throughput.
15
+
16
+ ## Recorded result
17
+
18
+ Measured 2026-07-13 on an Apple M4 Pro with 12 available physical CPUs,
19
+ Ruby 4.0.0, Prism 1.9.0, and Rails commit
20
+ `d7150bfc0109b33165cec73f24b1cdf17b252566` (3,433 Ruby files):
21
+
22
+ | Mode | Time |
23
+ |---|---:|
24
+ | 1 worker | 1.026s |
25
+ | 4 workers | 0.336s |
26
+ | 8 workers | 0.204s |
27
+ | 12 workers | 0.195s |
28
+ | Cold cache | 1.708s |
29
+ | Warm cache, new Runner | 0.056s |
30
+ | RuboCop, same files and equivalent rules | 16.659s |
31
+
32
+ The measured 8-worker speedup was 5.03x, the persistent warm cache was 30.5x
33
+ faster than the cold run, and the all-core run was 85.26x faster than RuboCop.
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "benchmark"
4
+ require "bundler"
5
+ require "etc"
6
+ require "open3"
7
+ require "tmpdir"
8
+ require "lumin"
9
+
10
+ root = File.expand_path(ARGV.fetch(0) { abort("usage: #{$PROGRAM_NAME} PROJECT_ROOT") })
11
+ paths = Dir.glob(File.join(root, "**", "*.rb"))
12
+ abort("no Ruby files found under #{root}") if paths.empty?
13
+
14
+ samples = Integer(ENV.fetch("SAMPLES", "3"), 10)
15
+ config = Lumin::Config.from_hash({})
16
+ measure = lambda do |workers|
17
+ Array.new(samples) do
18
+ Benchmark.realtime do
19
+ Lumin::Runner.new(config: config, parallel: workers, cache: false).run(paths)
20
+ end
21
+ end.sort.fetch(samples / 2)
22
+ end
23
+
24
+ puts "files: #{paths.length}"
25
+ available_cpus = Etc.nprocessors
26
+ puts "available CPUs: #{available_cpus}"
27
+ worker_counts = [1, 4, 8, available_cpus].uniq
28
+ timings = worker_counts.to_h do |workers|
29
+ elapsed = measure.call(workers)
30
+ puts format("workers=%d median: %.3fs", workers, elapsed)
31
+ [workers, elapsed]
32
+ end
33
+
34
+ Dir.mktmpdir("lumin-cache") do |cache_dir|
35
+ cold_runner = Lumin::Runner.new(config: config, parallel: false, cache_dir: cache_dir)
36
+ cold = Benchmark.realtime { cold_runner.run(paths) }
37
+ warm_runner = Lumin::Runner.new(config: config, parallel: false, cache_dir: cache_dir)
38
+ warm = Benchmark.realtime { warm_runner.run(paths) }
39
+ puts format("cache cold: %.3fs", cold)
40
+ puts format("cache warm (new runner): %.3fs", warm)
41
+ abort("warm cache is not at least 10x faster") if ENV["VERIFY"] == "1" && warm * 10 >= cold
42
+ end
43
+
44
+ speedup = timings.fetch(1) / timings.fetch(8)
45
+ puts format("8-worker speedup: %.2fx", speedup)
46
+ if ENV["VERIFY"] == "1" && Etc.nprocessors >= 8 && speedup < 5.0
47
+ abort(format("8-worker speedup is below 5x: %.2fx", speedup))
48
+ end
49
+
50
+ if ENV["RUBOCOP"] == "1"
51
+ rubocop_config = File.expand_path("rubocop.yml", __dir__)
52
+ command = ["rubocop", "--config", rubocop_config, "--cache", "false", "--format", "quiet", *paths]
53
+ status = nil
54
+ rubocop = Benchmark.realtime do
55
+ Bundler.with_unbundled_env do
56
+ _stdout, stderr, status = Open3.capture3(*command)
57
+ abort("RuboCop benchmark failed: #{stderr.lines.first}") unless [0, 1].include?(status.exitstatus)
58
+ end
59
+ end
60
+ fastest_lumin = timings.fetch(available_cpus)
61
+ ratio = rubocop / fastest_lumin
62
+ puts format("RuboCop: %.3fs", rubocop)
63
+ puts format("Lumin vs RuboCop: %.2fx", ratio)
64
+ abort(format("Lumin is less than 10x faster than RuboCop: %.2fx", ratio)) if ENV["VERIFY"] == "1" && ratio < 10
65
+ end
@@ -0,0 +1,11 @@
1
+ AllCops:
2
+ DisabledByDefault: true
3
+ NewCops: disable
4
+ TargetRubyVersion: 3.3
5
+
6
+ Layout/LineLength:
7
+ Enabled: true
8
+ Max: 120
9
+
10
+ Style/FrozenStringLiteralComment:
11
+ Enabled: true
data/exe/lumin ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "lumin/cli"
5
+
6
+ exit Lumin::CLI.start
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "tempfile"
5
+ require "astel"
6
+ require_relative "error"
7
+ require_relative "source_snapshot"
8
+
9
+ module Lumin
10
+ class Autocorrector
11
+ class CorrectionError < Error; end
12
+
13
+ Result = Data.define(:offenses, :corrected, :diff, :warnings, :content_digest, :fingerprint)
14
+
15
+ def initialize(worker, max_iterations: 5)
16
+ @worker = worker
17
+ @max_iterations = max_iterations
18
+ end
19
+
20
+ def call(path, unsafe: false, dry_run: false)
21
+ snapshot = SourceSnapshot.read(path)
22
+ original = snapshot.source
23
+ current = original
24
+ warnings = []
25
+ iterations = 0
26
+ source = parse(current, path)
27
+
28
+ @max_iterations.times do
29
+ iterations += 1
30
+ break unless source.valid?
31
+
32
+ offenses = @worker.lint_source(source)
33
+ corrections = offenses.select { |offense| offense.correctable? && (offense.safe || unsafe) }
34
+ break if corrections.empty?
35
+
36
+ rewriter = Astel::Rewriter.new(source)
37
+ corrections.each do |offense|
38
+ begin
39
+ offense.correction.call(rewriter)
40
+ rescue Astel::Rewriter::ConflictError
41
+ warnings << "#{path}: skipped conflicting correction for #{offense.rule_name}"
42
+ end
43
+ end
44
+ break if rewriter.edits.empty?
45
+
46
+ rewritten = rewriter.rewrite
47
+ break if rewritten == current
48
+
49
+ parsed = parse(rewritten, path)
50
+ raise CorrectionError, "autocorrection produced invalid Ruby for #{path}" unless parsed.valid?
51
+
52
+ current = rewritten
53
+ source = parsed
54
+ end
55
+
56
+ final_offenses = @worker.lint_source(source)
57
+ remaining = final_offenses.any? { |offense| offense.correctable? && (offense.safe || unsafe) }
58
+ if iterations == @max_iterations && remaining
59
+ warnings << "#{path}: autocorrection did not converge after #{@max_iterations} iterations"
60
+ end
61
+ corrected = current != original
62
+ fingerprint = if corrected && !dry_run
63
+ write_atomically(path, current, expected_fingerprint: snapshot.fingerprint)
64
+ elsif corrected
65
+ nil
66
+ else
67
+ snapshot.fingerprint
68
+ end
69
+ Result.new(
70
+ offenses: final_offenses,
71
+ corrected: corrected,
72
+ diff: corrected && dry_run ? unified_diff(path, original, current) : nil,
73
+ warnings: warnings.uniq.freeze,
74
+ content_digest: Digest::SHA256.hexdigest(current),
75
+ fingerprint: fingerprint
76
+ )
77
+ end
78
+
79
+ private
80
+
81
+ def parse(source, path)
82
+ Astel::SourceFile.from_string(source, path: path, version: @worker.config.target_ruby)
83
+ end
84
+
85
+ def write_atomically(path, content, expected_fingerprint:)
86
+ stat = File.stat(path)
87
+ Tempfile.create([".lumin", ".rb"], File.dirname(File.expand_path(path))) do |file|
88
+ file.binmode
89
+ file.write(content)
90
+ file.flush
91
+ file.fsync
92
+ File.chmod(stat.mode, file.path)
93
+ file.close
94
+ unless SourceSnapshot.fingerprint(path) == expected_fingerprint
95
+ raise CorrectionError, "#{path} changed during autocorrection; refusing to overwrite it"
96
+ end
97
+ File.rename(file.path, path)
98
+ end
99
+ written_fingerprint(path, content)
100
+ end
101
+
102
+ def written_fingerprint(path, content)
103
+ snapshot = SourceSnapshot.read(path)
104
+ snapshot.fingerprint if snapshot.source == content
105
+ rescue SourceSnapshot::ChangedError, SystemCallError
106
+ nil
107
+ end
108
+
109
+ def unified_diff(path, before, after)
110
+ before_lines = before.lines
111
+ after_lines = after.lines
112
+ output = +"--- #{path}\n+++ #{path}\n"
113
+ output << "@@ -1,#{before_lines.length} +1,#{after_lines.length} @@\n"
114
+ before_lines.each do |line|
115
+ output << "-#{line}"
116
+ output << "\n" unless line.end_with?("\n")
117
+ end
118
+ after_lines.each do |line|
119
+ output << "+#{line}"
120
+ output << "\n" unless line.end_with?("\n")
121
+ end
122
+ output
123
+ end
124
+ end
125
+ end