lintus 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 981afee300e237f8f7f4435a0ed6e8501caa8d144736eedca394711dfd8c7ad2
4
- data.tar.gz: '07488e702c7d8b7f6f43a9bad53add49f5ab723e9b730fbb1514fddf7a860cdf'
3
+ metadata.gz: 52defdb195114923372f370f0da8c0a2a676968a0feb338af762294f718fd006
4
+ data.tar.gz: 7dc54bf3c0e478c2233d3ae1a2ac84819959ee5cbcfa1ab06c9058b7aedd19ee
5
5
  SHA512:
6
- metadata.gz: a1660f4d328e3ffba835656b98a8d7d6b856b4f48f5738021d6925ecd79579ab8b50f44a7a675d10dadef66ea3b554ad57afaa2a33e391df2e9de8831f451c7f
7
- data.tar.gz: 3c53cb7d2a59175e3fa8a01dfcce390c6fb434700e5439cb76f9067d5472498b11fa1e3416f7f695405d595ee3b03f50eca3811ca822e51fd94c6d911105bb86
6
+ metadata.gz: a09d084dd3238c1ba62570cdc3c06b30ac96082a9e044f3b19d1fbe1cf1323ce3a300dddce3347ef387069b967bf35437e2a384da1ad6127d2f0e613f819c6a3
7
+ data.tar.gz: 1d13affd795e60d4a371294741e37d9defc08358d4e0c92751d516d8925f6387ab147829393c9b0519f79c6614cb4a91e3185f8c57acb1b5d1626d39b5023417
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-09-21
4
+
5
+ - A progress counter on stderr while files are being checked.
6
+ - The JSON output lists every file with the probability each rule gave it, not only offenses.
7
+ - The starter config's documentation rule asks about the offense and scopes it with criteria,
8
+ so namespace wrappers no longer trip it.
9
+ - Removed rule `severity` and the `--fail-on` flag: an offense is an offense. A config that still
10
+ sets `severity` is rejected with a message naming the key.
11
+
3
12
  ## [0.1.0] - 2026-09-21
4
13
 
5
14
  - Initial release.
data/README.md CHANGED
@@ -19,7 +19,7 @@ rules:
19
19
 
20
20
  ```
21
21
  $ lintus
22
- app/jobs/retry_job.rb: [no_sleep_in_jobs] Background jobs must never block on sleep. (error, noul 0.94)
22
+ app/jobs/retry_job.rb: [no_sleep_in_jobs] Background jobs must never block on sleep. (noul 0.94)
23
23
 
24
24
  42 files inspected, 1 offense detected
25
25
  ```
@@ -81,13 +81,11 @@ rules:
81
81
  - "app/**/*.rb"
82
82
  exclude:
83
83
  - "app/models/legacy/**"
84
- severity: error
85
84
 
86
85
  service_objects_are_documented:
87
86
  description: Service objects carry a comment explaining what they do.
88
87
  question: Does every class in this file have a comment describing its responsibility?
89
88
  offense_when: false
90
- severity: warning
91
89
  paths:
92
90
  - "app/services/**/*.rb"
93
91
  ```
@@ -102,7 +100,6 @@ rules:
102
100
  | `threshold` | no | Probability above which the answer counts as `true`. Defaults to 0.5. Raise it for rules where a false positive is costly. |
103
101
  | `paths` | no | Globs the rule applies to. Defaults to the top-level `paths`; with neither, every file. |
104
102
  | `exclude` | no | Globs the rule never applies to, on top of the top-level `exclude`. |
105
- | `severity` | no | `error` (default) or `warning`. Only errors fail the run, unless `--fail-on` says otherwise. |
106
103
  | `offense_when` | no | `true` (default) or `false`. Set to `false` for rules phrased positively, such as "Does every class have a comment?". |
107
104
 
108
105
  Rule ids are snake_case. They become the question identifiers in the Jev request and the
@@ -121,6 +118,22 @@ model. So prefer several narrow questions over one broad one: "Does this file ca
121
118
  and "Does this file rescue `Exception`?" as two rules beat "Does this file do anything a job
122
119
  should not?".
123
120
 
121
+ **Ask about the offense, not about compliance.** "Does every class have a comment?" turns
122
+ false on a single edge case, and there is always one: the namespace wrapper, the reopened
123
+ class, the one-line error subclass. "Is there a class without a comment?" is the same
124
+ question, but its `criteria` can now spell out which classes count. Keep `offense_when: false`
125
+ for questions that are genuinely easier to phrase positively.
126
+
127
+ **Name the unit and list what to ignore.** The model reads a question literally. When Lintus
128
+ first linted itself with "does every class or module have a comment?", every file was flagged
129
+ with a probability around 0.15: each one opens with an undocumented `module Lintus`. The fix
130
+ was not the threshold but the criteria, which now exclude wrappers whose body only nests other
131
+ definitions.
132
+
133
+ **Read the probabilities before touching the threshold.** Scores clustered far from 0.5 mean
134
+ the model is sure of its reading of the question. If that reading is not yours, reword. Move
135
+ the threshold only when the scores of clean and offending files overlap around it.
136
+
124
137
  Lintus sends the model the file's path and its full content. A question can therefore refer to
125
138
  the file name ("Is this a controller?") as well as the code.
126
139
 
@@ -140,11 +153,15 @@ Only files that at least one rule applies to are sent. Deleted files are never s
140
153
 
141
154
  `--format text` is the default. `--format github` prints GitHub Actions workflow commands, so
142
155
  each offense becomes an annotation on the file in the pull request; it is the default when
143
- `GITHUB_ACTIONS` is set. `--format json` is for other tools.
156
+ `GITHUB_ACTIONS` is set. `--format json` is for other tools, and it also lists the probability
157
+ every rule gave every file under `files`, offense or not, which is what you want when tuning
158
+ a rule's wording or threshold.
159
+
160
+ Exit status is `0` when clean, `1` when there are offenses, and `2` when a request failed or
161
+ the invocation was wrong.
144
162
 
145
- Exit status is `0` when clean, `1` when there are offenses at or above `--fail-on`
146
- (`error` by default, or `warning`, or `never`), and `2` when a request failed or the
147
- invocation was wrong.
163
+ While a run is in flight, a counter on stderr shows how many files are done. Off a terminal
164
+ (CI logs, pipes) it is a single line announcing the run, so captured output stays clean.
148
165
 
149
166
  ## GitHub Actions
150
167
 
@@ -163,7 +180,7 @@ jobs:
163
180
  - uses: actions/checkout@v4
164
181
  with:
165
182
  fetch-depth: 0 # needed to diff against the base branch
166
- - uses: virolea/lintus@v0.1.0
183
+ - uses: virolea/lintus@v0.2.0
167
184
  with:
168
185
  jev-api-key: ${{ secrets.JEV_API_KEY }}
169
186
  ```
@@ -180,7 +197,7 @@ With [pre-commit](https://pre-commit.com):
180
197
  ```yaml
181
198
  repos:
182
199
  - repo: https://github.com/virolea/lintus
183
- rev: v0.1.0
200
+ rev: v0.2.0
184
201
  hooks:
185
202
  - id: lintus
186
203
  ```
@@ -204,7 +221,6 @@ Either way `JEV_API_KEY` must be in the environment of the shell running the com
204
221
  -c, --config PATH Config file to use instead of searching for one
205
222
  -j, --jobs N Concurrent requests to the Jev API (default 4)
206
223
  --api-key KEY Jev API key (default: $JEV_API_KEY)
207
- --fail-on LEVEL error (default), warning, or never
208
224
  -l, --list Show what would be checked without calling the API
209
225
  ```
210
226
 
@@ -223,7 +239,7 @@ The repository lints itself: see `.lintus.yml` and `.github/workflows/lintus.yml
223
239
  Bump `lib/lintus/version.rb`, add the entry to `CHANGELOG.md`, and push a matching tag:
224
240
 
225
241
  ```bash
226
- git tag v0.1.0 && git push origin v0.1.0
242
+ git tag v0.2.0 && git push origin v0.2.0
227
243
  ```
228
244
 
229
245
  The release workflow checks that the tag matches the version, runs the tests, and pushes the
data/action.yml CHANGED
@@ -17,7 +17,7 @@ inputs:
17
17
  required: false
18
18
  default: ""
19
19
  args:
20
- description: Extra arguments for the lintus command, for example "--fail-on warning" or "--jobs 8".
20
+ description: Extra arguments for the lintus command, for example "--jobs 8".
21
21
  required: false
22
22
  default: ""
23
23
  version:
data/lib/lintus/cli.rb CHANGED
@@ -15,7 +15,7 @@ module Lintus
15
15
  # `command` is what to do; `selection` is which files, with `ref` for --diff
16
16
  # and `files` for paths given on the command line.
17
17
  Options = Struct.new(
18
- :command, :selection, :ref, :files, :config, :format, :jobs, :api_key, :list, :fail_on,
18
+ :command, :selection, :ref, :files, :config, :format, :jobs, :api_key, :list,
19
19
  keyword_init: true
20
20
  )
21
21
 
@@ -50,9 +50,9 @@ module Lintus
50
50
  return list(tasks) if options.list
51
51
 
52
52
  configure_api_key!(options)
53
- report = runner.run(tasks)
53
+ report = with_progress(tasks.size) { |progress| runner.run(tasks) { |done| progress.update(done) } }
54
54
  Formatter.for(options.format).new(stdout).render(report)
55
- report.exit_status(fail_on: options.fail_on)
55
+ report.exit_status
56
56
  end
57
57
 
58
58
  def parse(argv)
@@ -79,7 +79,7 @@ module Lintus
79
79
  def default_options
80
80
  Options.new(
81
81
  command: :lint, selection: :all, format: @env["GITHUB_ACTIONS"] == "true" ? "github" : "text",
82
- jobs: 4, api_key: @env["JEV_API_KEY"], list: false, fail_on: "error"
82
+ jobs: 4, api_key: @env["JEV_API_KEY"], list: false
83
83
  )
84
84
  end
85
85
 
@@ -109,9 +109,6 @@ module Lintus
109
109
  options.jobs = jobs
110
110
  end
111
111
  parser.on("--api-key KEY", "Jev API key (default: $JEV_API_KEY)") { |key| options.api_key = key }
112
- parser.on("--fail-on LEVEL", %w[error warning never], "Exit non-zero on: error (default), warning, never") do |level|
113
- options.fail_on = level
114
- end
115
112
  parser.on("-l", "--list", "List the files and rules that would be checked, without calling the API") do
116
113
  options.list = true
117
114
  end
@@ -138,6 +135,14 @@ module Lintus
138
135
  end
139
136
  end
140
137
 
138
+ def with_progress(total)
139
+ progress = Progress.new(stderr, total)
140
+ progress.start
141
+ yield progress
142
+ ensure
143
+ progress.finish
144
+ end
145
+
141
146
  def configure_api_key!(options)
142
147
  raise Error, "no API key: set JEV_API_KEY or pass --api-key" if options.api_key.nil? || options.api_key.empty?
143
148
 
@@ -8,7 +8,7 @@ module Lintus
8
8
  private
9
9
 
10
10
  def offense_line(offense)
11
- command(offense.severity, offense.message, file: offense.path, title: "lintus: #{offense.rule.id}")
11
+ command("error", offense.message, file: offense.path, title: "lintus: #{offense.rule.id}")
12
12
  end
13
13
 
14
14
  def skipped_line(skipped)
@@ -11,16 +11,22 @@ module Lintus
11
11
  summary: {
12
12
  files_inspected: report.checked.size,
13
13
  offenses: report.offenses.size,
14
- errors: report.errors.size,
15
- warnings: report.warnings.size,
16
14
  skipped: report.skipped.size,
17
15
  failures: report.failures.size
18
16
  },
19
17
  offenses: report.sorted_offenses.map(&:to_h),
18
+ files: report.checked.sort_by(&:path).map { |checked| file_entry(checked) },
20
19
  skipped: report.skipped.map { |skipped| { path: skipped.path, reason: skipped.reason } },
21
20
  failures: report.failures.map { |failure| { path: failure.path, error: failure.error.message } }
22
21
  )
23
22
  end
23
+
24
+ private
25
+
26
+ # Every rule asked about the file and the probability it got, offense or not.
27
+ def file_entry(checked)
28
+ { path: checked.path, answers: checked.answers.transform_values { |noul| noul.round(3) } }
29
+ end
24
30
  end
25
31
  end
26
32
  end
@@ -7,7 +7,7 @@ module Lintus
7
7
  private
8
8
 
9
9
  def offense_line(offense)
10
- "#{offense.path}: [#{offense.rule.id}] #{offense.message} (#{offense.severity}, noul #{offense.noul.round(2)})"
10
+ "#{offense.path}: [#{offense.rule.id}] #{offense.message} (noul #{offense.noul.round(2)})"
11
11
  end
12
12
 
13
13
  def skipped_line(skipped) = "#{skipped.path}: skipped, #{skipped.reason}"
@@ -3,12 +3,10 @@
3
3
  module Lintus
4
4
  # A rule the model flagged on a file, with the probability it gave.
5
5
  Offense = Struct.new(:path, :rule, :noul, keyword_init: true) do
6
- def severity = rule.severity
7
- def error? = rule.error?
8
6
  def message = rule.description
9
7
 
10
8
  def to_h
11
- { path: path, rule: rule.id, severity: severity, message: message, noul: noul.round(3) }
9
+ { path: path, rule: rule.id, message: message, noul: noul.round(3) }
12
10
  end
13
11
  end
14
12
  end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # Tells the person waiting that something is happening. On a terminal it is a
5
+ # counter updated in place and erased when done; elsewhere (CI logs, pipes) it
6
+ # is a single line announcing the run, so nothing garbles captured output.
7
+ class Progress
8
+ def initialize(io, total)
9
+ @io = io
10
+ @total = total
11
+ @tty = io.respond_to?(:tty?) && io.tty?
12
+ @width = 0
13
+ end
14
+
15
+ def start
16
+ return if @total.zero?
17
+
18
+ if @tty
19
+ draw(0)
20
+ else
21
+ @io.puts("Checking #{@total} #{@total == 1 ? "file" : "files"}...")
22
+ end
23
+ end
24
+
25
+ def update(done)
26
+ draw(done) if @tty
27
+ end
28
+
29
+ def finish
30
+ return unless @tty && @width.positive?
31
+
32
+ @io.print "\r#{" " * @width}\r"
33
+ @io.flush
34
+ end
35
+
36
+ private
37
+
38
+ def draw(done)
39
+ line = "Checking #{done}/#{@total} files"
40
+ @width = [@width, line.size].max
41
+ @io.print "\r#{line.ljust(@width)}"
42
+ @io.flush
43
+ end
44
+ end
45
+ end
data/lib/lintus/report.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  module Lintus
4
4
  # Everything a run produced: what was checked, what was flagged, what was skipped, what broke.
5
5
  class Report
6
+ Checked = Struct.new(:path, :answers, keyword_init: true)
6
7
  Skipped = Struct.new(:path, :reason, keyword_init: true)
7
8
  Failure = Struct.new(:path, :error, keyword_init: true)
8
9
 
@@ -16,9 +17,11 @@ module Lintus
16
17
  @mutex = Mutex.new
17
18
  end
18
19
 
19
- def record_checked(path, offenses)
20
+ # `answers` maps every rule asked about the file to the probability it got,
21
+ # offense or not, so a run can be read for confidence and not just verdicts.
22
+ def record_checked(path, offenses, answers = {})
20
23
  synchronize do
21
- checked << path
24
+ checked << Checked.new(path: path, answers: answers)
22
25
  self.offenses.concat(offenses)
23
26
  end
24
27
  end
@@ -31,21 +34,13 @@ module Lintus
31
34
  synchronize { failures << Failure.new(path: path, error: error) }
32
35
  end
33
36
 
34
- def errors = offenses.select(&:error?)
35
- def warnings = offenses.reject(&:error?)
36
-
37
37
  def sorted_offenses = offenses.sort_by { |offense| [offense.path, offense.rule.id] }
38
38
 
39
- # 2 when a file could not be checked, 1 when offenses reach `fail_on`, else 0.
40
- def exit_status(fail_on: "error")
39
+ # 2 when a file could not be checked, 1 when there are offenses, else 0.
40
+ def exit_status
41
41
  return 2 if failures.any?
42
42
 
43
- failing = case fail_on.to_s
44
- when "warning" then offenses
45
- when "never" then []
46
- else errors
47
- end
48
- failing.any? ? 1 : 0
43
+ offenses.any? ? 1 : 0
49
44
  end
50
45
 
51
46
  private
data/lib/lintus/rule.rb CHANGED
@@ -8,11 +8,10 @@ module Lintus
8
8
  # `offense_when` (true by default, so questions are phrased to describe the
9
9
  # offense: "Does this file call sleep?").
10
10
  class Rule
11
- KEYS = %w[question description criteria threshold paths exclude severity offense_when].freeze
12
- SEVERITIES = %w[error warning].freeze
11
+ KEYS = %w[question description criteria threshold paths exclude offense_when].freeze
13
12
  ID_FORMAT = /\A[a-z][a-z0-9_]*\z/
14
13
 
15
- attr_reader :id, :description, :question, :criteria, :threshold, :paths, :exclude, :severity, :offense_when
14
+ attr_reader :id, :description, :question, :criteria, :threshold, :paths, :exclude, :offense_when
16
15
 
17
16
  def initialize(id, attrs, default_paths: [], default_exclude: [])
18
17
  @id = validate_id(id)
@@ -24,7 +23,6 @@ module Lintus
24
23
  @threshold = build_threshold(attrs["threshold"])
25
24
  @paths = Schema.string_list(attrs.fetch("paths", default_paths))
26
25
  @exclude = default_exclude + Schema.string_list(attrs["exclude"])
27
- @severity = build_severity(attrs.fetch("severity", "error"))
28
26
  @offense_when = build_offense_when(attrs.fetch("offense_when", true))
29
27
  end
30
28
 
@@ -41,8 +39,6 @@ module Lintus
41
39
 
42
40
  def offense?(answer) = answer.result == offense_when
43
41
 
44
- def error? = severity == "error"
45
-
46
42
  private
47
43
 
48
44
  def validate_id(id)
@@ -86,16 +82,6 @@ module Lintus
86
82
  threshold.to_f
87
83
  end
88
84
 
89
- def build_severity(severity)
90
- severity = severity.to_s
91
- unless SEVERITIES.include?(severity)
92
- raise ConfigError,
93
- "rule #{id}: `severity` must be one of #{SEVERITIES.join(", ")}"
94
- end
95
-
96
- severity
97
- end
98
-
99
85
  def build_offense_when(value)
100
86
  raise ConfigError, "rule #{id}: `offense_when` must be true or false" unless [true, false].include?(value)
101
87
 
data/lib/lintus/runner.rb CHANGED
@@ -23,17 +23,21 @@ module Lintus
23
23
  end
24
24
  end
25
25
 
26
- # Checks the [file, rules] pairs from #plan concurrently.
26
+ # Checks the [file, rules] pairs from #plan concurrently. The block, if
27
+ # given, is called with the number of files done after each one finishes.
27
28
  def run(tasks)
28
29
  report = Report.new
29
30
  queue = Queue.new
30
31
  tasks.each { |task| queue << task }
31
32
  queue.close
33
+ done = 0
34
+ counter = Mutex.new
32
35
 
33
36
  workers = Array.new([jobs, tasks.size].min) do
34
37
  Thread.new do
35
38
  while (task = queue.pop)
36
39
  check(*task, report)
40
+ counter.synchronize { yield(done += 1) } if block_given?
37
41
  end
38
42
  end
39
43
  end
@@ -50,7 +54,8 @@ module Lintus
50
54
  end
51
55
 
52
56
  response = with_retries { perform(file, rules, content) }
53
- report.record_checked(file.path, offenses_for(file, rules, response.answers))
57
+ answers = rules.to_h { |rule| [rule.id, response.answers[rule.id].noul] }
58
+ report.record_checked(file.path, offenses_for(file, rules, response.answers), answers)
54
59
  rescue Jev::Error, Error => e
55
60
  report.record_failure(file.path, e)
56
61
  end
@@ -28,7 +28,6 @@ module Lintus
28
28
  criteria:
29
29
  "true": A debugger breakpoint or a throwaway print statement is present.
30
30
  "false": Any output is deliberate program behaviour, or there is none.
31
- severity: error
32
31
 
33
32
  no_hardcoded_secrets:
34
33
  description: Secrets must come from the environment or a credentials store, never source code.
@@ -37,14 +36,15 @@ module Lintus
37
36
  "true": A literal credential value is written in the source.
38
37
  "false": Credentials are read from configuration, or there are none.
39
38
  threshold: 0.7
40
- severity: error
41
39
 
42
- methods_are_documented:
43
- description: Public classes should carry a short comment explaining their purpose.
44
- question: Does every class or module defined in this file have a comment describing its responsibility?
45
- # This rule is phrased positively, so the offense is a "false" answer.
46
- offense_when: false
47
- severity: warning
40
+ classes_are_documented:
41
+ description: Classes should open with a short comment explaining their purpose.
42
+ question: Is there a class or module defined in this file whose definition is not preceded by a comment describing what it is for?
43
+ criteria:
44
+ "true": A class or module with a body of its own has no descriptive comment right above its definition.
45
+ "false": >-
46
+ Every class or module that carries behaviour has such a comment, or the file defines none.
47
+ Ignore namespace wrappers that only nest other definitions and one-line subclasses.
48
48
  YAML
49
49
  end
50
50
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lintus
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lintus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vincent Rolea
@@ -67,6 +67,7 @@ files:
67
67
  - lib/lintus/git.rb
68
68
  - lib/lintus/glob.rb
69
69
  - lib/lintus/offense.rb
70
+ - lib/lintus/progress.rb
70
71
  - lib/lintus/report.rb
71
72
  - lib/lintus/rule.rb
72
73
  - lib/lintus/runner.rb