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
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
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
data/benchmark/README.md
ADDED
|
@@ -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
|
data/exe/lumin
ADDED
|
@@ -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
|