lintus 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/.pre-commit-hooks.yaml +18 -0
- data/CHANGELOG.md +11 -0
- data/LICENSE.txt +21 -0
- data/README.md +239 -0
- data/Rakefile +12 -0
- data/action.yml +69 -0
- data/exe/lintus +6 -0
- data/lib/lintus/cli.rb +162 -0
- data/lib/lintus/config.rb +77 -0
- data/lib/lintus/file_finder.rb +67 -0
- data/lib/lintus/formatter/github.rb +32 -0
- data/lib/lintus/formatter/json.rb +26 -0
- data/lib/lintus/formatter/text.rb +22 -0
- data/lib/lintus/formatter.rb +47 -0
- data/lib/lintus/git.rb +52 -0
- data/lib/lintus/glob.rb +38 -0
- data/lib/lintus/offense.rb +14 -0
- data/lib/lintus/report.rb +55 -0
- data/lib/lintus/rule.rb +105 -0
- data/lib/lintus/runner.rb +97 -0
- data/lib/lintus/schema.rb +17 -0
- data/lib/lintus/source_file.rb +21 -0
- data/lib/lintus/template.rb +50 -0
- data/lib/lintus/version.rb +5 -0
- data/lib/lintus.rb +22 -0
- metadata +104 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 981afee300e237f8f7f4435a0ed6e8501caa8d144736eedca394711dfd8c7ad2
|
|
4
|
+
data.tar.gz: '07488e702c7d8b7f6f43a9bad53add49f5ab723e9b730fbb1514fddf7a860cdf'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a1660f4d328e3ffba835656b98a8d7d6b856b4f48f5738021d6925ecd79579ab8b50f44a7a675d10dadef66ea3b554ad57afaa2a33e391df2e9de8831f451c7f
|
|
7
|
+
data.tar.gz: 3c53cb7d2a59175e3fa8a01dfcce390c6fb434700e5439cb76f9067d5472498b11fa1e3416f7f695405d595ee3b03f50eca3811ca822e51fd94c6d911105bb86
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Hook definition for https://pre-commit.com
|
|
2
|
+
#
|
|
3
|
+
# repos:
|
|
4
|
+
# - repo: https://github.com/virolea/lintus
|
|
5
|
+
# rev: v0.1.0
|
|
6
|
+
# hooks:
|
|
7
|
+
# - id: lintus
|
|
8
|
+
#
|
|
9
|
+
# pre-commit passes the staged file names as arguments, so only the files in
|
|
10
|
+
# the commit are sent to the model. JEV_API_KEY must be set in the environment.
|
|
11
|
+
- id: lintus
|
|
12
|
+
name: lintus
|
|
13
|
+
description: Lint staged files against the plain-language rules in .lintus.yml, using the Jev model.
|
|
14
|
+
entry: lintus
|
|
15
|
+
language: ruby
|
|
16
|
+
pass_filenames: true
|
|
17
|
+
require_serial: true
|
|
18
|
+
additional_dependencies: []
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
## [Unreleased]
|
|
2
|
+
|
|
3
|
+
## [0.1.0] - 2026-09-21
|
|
4
|
+
|
|
5
|
+
- Initial release.
|
|
6
|
+
- Rules are read from `.lintus.yml` at the repository root and asked to the Jev model as noul questions.
|
|
7
|
+
- Per-rule `paths`, `exclude`, `criteria`, `threshold`, `severity` and `offense_when`.
|
|
8
|
+
- File selection: whole tree, explicit files or directories, `--diff [REF]`, and `--staged`.
|
|
9
|
+
- Output formats: `text`, `github` (workflow annotations) and `json`.
|
|
10
|
+
- `lintus init` writes a starter config; `--list` shows what would be sent without calling the API.
|
|
11
|
+
- A composite GitHub Action (`action.yml`) and a pre-commit hook definition.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vincent Rolea
|
|
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,239 @@
|
|
|
1
|
+
# Lintus
|
|
2
|
+
|
|
3
|
+
A linter whose rules are written in plain language.
|
|
4
|
+
|
|
5
|
+
Lintus reads a YAML file of rules at the root of your repository. Each rule is a question
|
|
6
|
+
about a file. For every file a rule applies to, Lintus asks the question to the
|
|
7
|
+
[Jev](https://github.com/virolea/jev) model as a *noul* (a true/false judgement with a
|
|
8
|
+
probability), and reports an offense wherever the answer says so.
|
|
9
|
+
|
|
10
|
+
```yaml
|
|
11
|
+
# .lintus.yml
|
|
12
|
+
rules:
|
|
13
|
+
no_sleep_in_jobs:
|
|
14
|
+
description: Background jobs must never block on sleep.
|
|
15
|
+
question: Does this file call `sleep` inside a job's perform method?
|
|
16
|
+
paths:
|
|
17
|
+
- "app/jobs/**/*.rb"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
$ lintus
|
|
22
|
+
app/jobs/retry_job.rb: [no_sleep_in_jobs] Background jobs must never block on sleep. (error, noul 0.94)
|
|
23
|
+
|
|
24
|
+
42 files inspected, 1 offense detected
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
It runs on the whole tree, on the files changed since a git ref, or on the files staged for a
|
|
28
|
+
commit, so it fits a CI job as well as a pre-commit hook.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
gem install lintus
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or add it to your `Gemfile`:
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
gem "lintus", group: :development
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Lintus needs a Jev API key. Export it as `JEV_API_KEY` or pass `--api-key`.
|
|
43
|
+
|
|
44
|
+
## Getting started
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
lintus init # writes a starter .lintus.yml
|
|
48
|
+
lintus --list # shows which files each rule would be asked about, without calling the API
|
|
49
|
+
lintus # lints every file
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## The config file
|
|
53
|
+
|
|
54
|
+
Lintus looks for `.lintus.yml` (or `lintus.yml`, `.lintus.yaml`, `lintus.yaml`) in the current
|
|
55
|
+
directory and its parents, the way git finds `.git`. The directory holding the file is the root
|
|
56
|
+
every path is relative to.
|
|
57
|
+
|
|
58
|
+
```yaml
|
|
59
|
+
# Globs every rule applies to, unless the rule has its own `paths`.
|
|
60
|
+
paths:
|
|
61
|
+
- "app/**/*.rb"
|
|
62
|
+
- "lib/**/*.rb"
|
|
63
|
+
|
|
64
|
+
# Globs no rule ever applies to.
|
|
65
|
+
exclude:
|
|
66
|
+
- "vendor/**"
|
|
67
|
+
- "db/schema.rb"
|
|
68
|
+
|
|
69
|
+
# Files larger than this many bytes are skipped instead of sent to the model. Default 100000.
|
|
70
|
+
max_file_size: 100000
|
|
71
|
+
|
|
72
|
+
rules:
|
|
73
|
+
no_raw_sql:
|
|
74
|
+
description: Build queries with Active Record, not string interpolation.
|
|
75
|
+
question: Does this file build a SQL string by interpolating or concatenating values into it?
|
|
76
|
+
criteria:
|
|
77
|
+
"true": A SQL fragment is assembled from Ruby values with #{}, +, or format.
|
|
78
|
+
"false": Queries go through Active Record methods or bound parameters, or there is no SQL.
|
|
79
|
+
threshold: 0.7
|
|
80
|
+
paths:
|
|
81
|
+
- "app/**/*.rb"
|
|
82
|
+
exclude:
|
|
83
|
+
- "app/models/legacy/**"
|
|
84
|
+
severity: error
|
|
85
|
+
|
|
86
|
+
service_objects_are_documented:
|
|
87
|
+
description: Service objects carry a comment explaining what they do.
|
|
88
|
+
question: Does every class in this file have a comment describing its responsibility?
|
|
89
|
+
offense_when: false
|
|
90
|
+
severity: warning
|
|
91
|
+
paths:
|
|
92
|
+
- "app/services/**/*.rb"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Rule attributes
|
|
96
|
+
|
|
97
|
+
| Key | Required | Meaning |
|
|
98
|
+
| -------------- | -------- | ------- |
|
|
99
|
+
| `question` | yes | The noul statement asked of the model. Phrase it so that `true` means the file is an offense. |
|
|
100
|
+
| `description` | no | The message printed for an offense. Defaults to the question. |
|
|
101
|
+
| `criteria` | no | A map with `"true"` and `"false"` keys spelling out what each answer means. This is how you scope an ambiguous question, and it is worth writing for every rule that matters. |
|
|
102
|
+
| `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
|
+
| `paths` | no | Globs the rule applies to. Defaults to the top-level `paths`; with neither, every file. |
|
|
104
|
+
| `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
|
+
| `offense_when` | no | `true` (default) or `false`. Set to `false` for rules phrased positively, such as "Does every class have a comment?". |
|
|
107
|
+
|
|
108
|
+
Rule ids are snake_case. They become the question identifiers in the Jev request and the
|
|
109
|
+
`[tag]` in the output.
|
|
110
|
+
|
|
111
|
+
### Globs
|
|
112
|
+
|
|
113
|
+
Globs use Ruby's `File.fnmatch` with pathname semantics: `*` does not cross directories,
|
|
114
|
+
`**/` does, and `{a,b}` alternation works. A bare directory (`vendor`) and a trailing `**`
|
|
115
|
+
(`vendor/**`) both match everything beneath it.
|
|
116
|
+
|
|
117
|
+
### Writing good rules
|
|
118
|
+
|
|
119
|
+
Every rule that applies to a file is sent in a single request, evaluated in parallel by the
|
|
120
|
+
model. So prefer several narrow questions over one broad one: "Does this file call `sleep`?"
|
|
121
|
+
and "Does this file rescue `Exception`?" as two rules beat "Does this file do anything a job
|
|
122
|
+
should not?".
|
|
123
|
+
|
|
124
|
+
Lintus sends the model the file's path and its full content. A question can therefore refer to
|
|
125
|
+
the file name ("Is this a controller?") as well as the code.
|
|
126
|
+
|
|
127
|
+
## Choosing files
|
|
128
|
+
|
|
129
|
+
| Command | Files |
|
|
130
|
+
| --------------------------- | ----- |
|
|
131
|
+
| `lintus` | Every file in the tree (tracked and untracked, honouring `.gitignore`). |
|
|
132
|
+
| `lintus app/jobs lib/x.rb` | The given files and directories. |
|
|
133
|
+
| `lintus --diff` | Files with uncommitted changes, plus untracked files. |
|
|
134
|
+
| `lintus --diff main` | Files changed since the merge base with `main`, plus uncommitted and untracked changes. |
|
|
135
|
+
| `lintus --staged` | Files staged for commit. The staged content is what gets linted, not the working tree. |
|
|
136
|
+
|
|
137
|
+
Only files that at least one rule applies to are sent. Deleted files are never sent.
|
|
138
|
+
|
|
139
|
+
## Output
|
|
140
|
+
|
|
141
|
+
`--format text` is the default. `--format github` prints GitHub Actions workflow commands, so
|
|
142
|
+
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.
|
|
144
|
+
|
|
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.
|
|
148
|
+
|
|
149
|
+
## GitHub Actions
|
|
150
|
+
|
|
151
|
+
```yaml
|
|
152
|
+
# .github/workflows/lintus.yml
|
|
153
|
+
name: Lintus
|
|
154
|
+
on: [pull_request]
|
|
155
|
+
|
|
156
|
+
permissions:
|
|
157
|
+
contents: read
|
|
158
|
+
|
|
159
|
+
jobs:
|
|
160
|
+
lintus:
|
|
161
|
+
runs-on: ubuntu-latest
|
|
162
|
+
steps:
|
|
163
|
+
- uses: actions/checkout@v4
|
|
164
|
+
with:
|
|
165
|
+
fetch-depth: 0 # needed to diff against the base branch
|
|
166
|
+
- uses: virolea/lintus@v0.1.0
|
|
167
|
+
with:
|
|
168
|
+
jev-api-key: ${{ secrets.JEV_API_KEY }}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
On pull requests the action lints only the files changed against the base branch, and
|
|
172
|
+
annotates them. Set `base: none` to lint everything, or `base: origin/develop` to diff
|
|
173
|
+
against another ref. Extra flags go in `args`. If the workflow already sets up Ruby, the
|
|
174
|
+
action reuses it.
|
|
175
|
+
|
|
176
|
+
## Pre-commit hook
|
|
177
|
+
|
|
178
|
+
With [pre-commit](https://pre-commit.com):
|
|
179
|
+
|
|
180
|
+
```yaml
|
|
181
|
+
repos:
|
|
182
|
+
- repo: https://github.com/virolea/lintus
|
|
183
|
+
rev: v0.1.0
|
|
184
|
+
hooks:
|
|
185
|
+
- id: lintus
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
pre-commit passes the staged file names, so only those are checked.
|
|
189
|
+
|
|
190
|
+
With a plain git hook, use `--staged` so the linted content is what is actually being
|
|
191
|
+
committed:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
#!/bin/sh
|
|
195
|
+
# .git/hooks/pre-commit
|
|
196
|
+
exec lintus --staged
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Either way `JEV_API_KEY` must be in the environment of the shell running the commit.
|
|
200
|
+
|
|
201
|
+
## Other options
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
-c, --config PATH Config file to use instead of searching for one
|
|
205
|
+
-j, --jobs N Concurrent requests to the Jev API (default 4)
|
|
206
|
+
--api-key KEY Jev API key (default: $JEV_API_KEY)
|
|
207
|
+
--fail-on LEVEL error (default), warning, or never
|
|
208
|
+
-l, --list Show what would be checked without calling the API
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Rate-limited and overloaded responses are retried with exponential backoff, three times per
|
|
212
|
+
file, before the file is reported as failed.
|
|
213
|
+
|
|
214
|
+
## Development
|
|
215
|
+
|
|
216
|
+
After checking out the repo, run `bin/setup` to install dependencies, then `bundle exec rake`
|
|
217
|
+
to run the tests and RuboCop. `bin/console` gives you an IRB session with the gem loaded.
|
|
218
|
+
|
|
219
|
+
The repository lints itself: see `.lintus.yml` and `.github/workflows/lintus.yml`.
|
|
220
|
+
|
|
221
|
+
### Releasing
|
|
222
|
+
|
|
223
|
+
Bump `lib/lintus/version.rb`, add the entry to `CHANGELOG.md`, and push a matching tag:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
git tag v0.1.0 && git push origin v0.1.0
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The release workflow checks that the tag matches the version, runs the tests, and pushes the
|
|
230
|
+
gem to rubygems.org through trusted publishing. Alternatively, `bundle exec rake release` does
|
|
231
|
+
the same from a machine that is signed in to rubygems.org.
|
|
232
|
+
|
|
233
|
+
## Contributing
|
|
234
|
+
|
|
235
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/virolea/lintus.
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
data/action.yml
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
name: Lintus
|
|
2
|
+
description: Lint a repository against the plain-language rules in its .lintus.yml, using the Jev model.
|
|
3
|
+
author: Vincent Rolea
|
|
4
|
+
branding:
|
|
5
|
+
icon: check-circle
|
|
6
|
+
color: purple
|
|
7
|
+
|
|
8
|
+
inputs:
|
|
9
|
+
jev-api-key:
|
|
10
|
+
description: API key for the Typesafe Jev model. Store it as a repository secret.
|
|
11
|
+
required: true
|
|
12
|
+
base:
|
|
13
|
+
description: >-
|
|
14
|
+
Git ref to diff against; only files changed since it are linted. Defaults to the
|
|
15
|
+
pull request base branch on pull_request events, and to linting every file otherwise.
|
|
16
|
+
Pass "none" to always lint every file. Requires `fetch-depth: 0` on actions/checkout.
|
|
17
|
+
required: false
|
|
18
|
+
default: ""
|
|
19
|
+
args:
|
|
20
|
+
description: Extra arguments for the lintus command, for example "--fail-on warning" or "--jobs 8".
|
|
21
|
+
required: false
|
|
22
|
+
default: ""
|
|
23
|
+
version:
|
|
24
|
+
description: Version of the lintus gem to install. Defaults to the latest release.
|
|
25
|
+
required: false
|
|
26
|
+
default: ""
|
|
27
|
+
ruby-version:
|
|
28
|
+
description: Ruby version to set up when the workflow has not done so already.
|
|
29
|
+
required: false
|
|
30
|
+
default: "3.3"
|
|
31
|
+
|
|
32
|
+
runs:
|
|
33
|
+
using: composite
|
|
34
|
+
steps:
|
|
35
|
+
- name: Set up Ruby
|
|
36
|
+
uses: ruby/setup-ruby@v1
|
|
37
|
+
with:
|
|
38
|
+
ruby-version: ${{ inputs.ruby-version }}
|
|
39
|
+
|
|
40
|
+
- name: Install lintus
|
|
41
|
+
shell: bash
|
|
42
|
+
env:
|
|
43
|
+
LINTUS_VERSION: ${{ inputs.version }}
|
|
44
|
+
run: |
|
|
45
|
+
if [ -n "$LINTUS_VERSION" ]; then
|
|
46
|
+
gem install lintus --version "$LINTUS_VERSION"
|
|
47
|
+
else
|
|
48
|
+
gem install lintus
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
- name: Run lintus
|
|
52
|
+
shell: bash
|
|
53
|
+
env:
|
|
54
|
+
JEV_API_KEY: ${{ inputs.jev-api-key }}
|
|
55
|
+
LINTUS_BASE: ${{ inputs.base }}
|
|
56
|
+
LINTUS_ARGS: ${{ inputs.args }}
|
|
57
|
+
PR_BASE_REF: ${{ github.base_ref }}
|
|
58
|
+
run: |
|
|
59
|
+
base="$LINTUS_BASE"
|
|
60
|
+
if [ -z "$base" ] && [ -n "$PR_BASE_REF" ]; then
|
|
61
|
+
base="origin/$PR_BASE_REF"
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
if [ -n "$base" ] && [ "$base" != "none" ]; then
|
|
65
|
+
git fetch --no-tags origin "${base#origin/}" >/dev/null 2>&1 || true
|
|
66
|
+
lintus --format github --diff "$base" $LINTUS_ARGS
|
|
67
|
+
else
|
|
68
|
+
lintus --format github $LINTUS_ARGS
|
|
69
|
+
fi
|
data/exe/lintus
ADDED
data/lib/lintus/cli.rb
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Lintus
|
|
6
|
+
# The `lintus` command.
|
|
7
|
+
class CLI
|
|
8
|
+
USAGE = <<~USAGE
|
|
9
|
+
Usage: lintus [options] [FILE...]
|
|
10
|
+
lintus init
|
|
11
|
+
|
|
12
|
+
With no FILE, lints every file in the tree. FILE may be a file or a directory.
|
|
13
|
+
USAGE
|
|
14
|
+
|
|
15
|
+
# `command` is what to do; `selection` is which files, with `ref` for --diff
|
|
16
|
+
# and `files` for paths given on the command line.
|
|
17
|
+
Options = Struct.new(
|
|
18
|
+
:command, :selection, :ref, :files, :config, :format, :jobs, :api_key, :list, :fail_on,
|
|
19
|
+
keyword_init: true
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
attr_reader :stdout, :stderr
|
|
23
|
+
|
|
24
|
+
def initialize(stdout: $stdout, stderr: $stderr, env: ENV, dir: Dir.pwd)
|
|
25
|
+
@stdout = stdout
|
|
26
|
+
@stderr = stderr
|
|
27
|
+
@env = env
|
|
28
|
+
@dir = dir
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run(argv)
|
|
32
|
+
options = parse(argv)
|
|
33
|
+
|
|
34
|
+
case options.command
|
|
35
|
+
when :exit then 0
|
|
36
|
+
when :init then init
|
|
37
|
+
else lint(options)
|
|
38
|
+
end
|
|
39
|
+
rescue Error, Jev::Error, OptionParser::ParseError => e
|
|
40
|
+
stderr.puts "lintus: #{e.message}"
|
|
41
|
+
2
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def lint(options)
|
|
47
|
+
config = Config.load(options.config, dir: @dir)
|
|
48
|
+
runner = Runner.new(config, jobs: options.jobs)
|
|
49
|
+
tasks = runner.plan(find_files(config, options))
|
|
50
|
+
return list(tasks) if options.list
|
|
51
|
+
|
|
52
|
+
configure_api_key!(options)
|
|
53
|
+
report = runner.run(tasks)
|
|
54
|
+
Formatter.for(options.format).new(stdout).render(report)
|
|
55
|
+
report.exit_status(fail_on: options.fail_on)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def parse(argv)
|
|
59
|
+
options = default_options
|
|
60
|
+
positional = build_parser(options).parse(argv)
|
|
61
|
+
return options if options.command == :exit
|
|
62
|
+
|
|
63
|
+
if positional.first == "init"
|
|
64
|
+
raise OptionParser::InvalidArgument, "init takes no arguments" if positional.size > 1
|
|
65
|
+
|
|
66
|
+
options.command = :init
|
|
67
|
+
elsif positional.any?
|
|
68
|
+
if options.selection != :all
|
|
69
|
+
raise OptionParser::InvalidArgument,
|
|
70
|
+
"FILE arguments cannot be combined with --diff or --staged"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
options.selection = :explicit
|
|
74
|
+
options.files = positional
|
|
75
|
+
end
|
|
76
|
+
options
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def default_options
|
|
80
|
+
Options.new(
|
|
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"
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def build_parser(options) # rubocop:disable Metrics/MethodLength
|
|
87
|
+
OptionParser.new do |parser| # rubocop:disable Metrics/BlockLength
|
|
88
|
+
parser.banner = USAGE
|
|
89
|
+
parser.separator ""
|
|
90
|
+
parser.separator "Which files:"
|
|
91
|
+
parser.on("-d", "--diff [REF]", "Only files changed since REF (default HEAD: uncommitted changes)") do |ref|
|
|
92
|
+
options.selection = :diff
|
|
93
|
+
options.ref = ref || "HEAD"
|
|
94
|
+
end
|
|
95
|
+
parser.on("-s", "--staged", "Only staged files, reading their staged content (for pre-commit hooks)") do
|
|
96
|
+
options.selection = :staged
|
|
97
|
+
end
|
|
98
|
+
parser.separator ""
|
|
99
|
+
parser.separator "How to run:"
|
|
100
|
+
parser.on("-c", "--config PATH",
|
|
101
|
+
"Config file (default: nearest #{CONFIG_FILENAMES.first} upwards from the current directory)") do |path|
|
|
102
|
+
options.config = path
|
|
103
|
+
end
|
|
104
|
+
parser.on("-f", "--format FORMAT", Formatter::NAMES,
|
|
105
|
+
"Output format: #{Formatter::NAMES.join(", ")} (default: text, or github under GitHub Actions)") do |format|
|
|
106
|
+
options.format = format
|
|
107
|
+
end
|
|
108
|
+
parser.on("-j", "--jobs N", Integer, "Concurrent requests to the Jev API (default 4)") do |jobs|
|
|
109
|
+
options.jobs = jobs
|
|
110
|
+
end
|
|
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
|
+
parser.on("-l", "--list", "List the files and rules that would be checked, without calling the API") do
|
|
116
|
+
options.list = true
|
|
117
|
+
end
|
|
118
|
+
parser.separator ""
|
|
119
|
+
parser.on("-v", "--version", "Print the version") do
|
|
120
|
+
stdout.puts "lintus #{VERSION}"
|
|
121
|
+
options.command = :exit
|
|
122
|
+
end
|
|
123
|
+
parser.on("-h", "--help", "Print this help") do
|
|
124
|
+
stdout.puts parser
|
|
125
|
+
options.command = :exit
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def find_files(config, options)
|
|
131
|
+
finder = FileFinder.new(config.root)
|
|
132
|
+
|
|
133
|
+
case options.selection
|
|
134
|
+
when :diff then finder.changed_since(options.ref)
|
|
135
|
+
when :staged then finder.staged
|
|
136
|
+
when :explicit then finder.explicit(options.files, from: @dir)
|
|
137
|
+
else finder.all
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def configure_api_key!(options)
|
|
142
|
+
raise Error, "no API key: set JEV_API_KEY or pass --api-key" if options.api_key.nil? || options.api_key.empty?
|
|
143
|
+
|
|
144
|
+
Jev.api_key = options.api_key
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def list(tasks)
|
|
148
|
+
tasks.each { |file, rules| stdout.puts "#{file.path}: #{rules.map(&:id).join(", ")}" }
|
|
149
|
+
stdout.puts "#{tasks.size} file(s) would be checked"
|
|
150
|
+
0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def init
|
|
154
|
+
path = File.join(@dir, CONFIG_FILENAMES.first)
|
|
155
|
+
raise Error, "#{path} already exists" if File.exist?(path)
|
|
156
|
+
|
|
157
|
+
File.write(path, Template::CONFIG)
|
|
158
|
+
stdout.puts "Wrote #{path}"
|
|
159
|
+
0
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lintus
|
|
4
|
+
# The parsed config file. Its directory is the root every path is relative to.
|
|
5
|
+
class Config
|
|
6
|
+
DEFAULT_MAX_FILE_SIZE = 100_000
|
|
7
|
+
KEYS = %w[rules paths exclude max_file_size].freeze
|
|
8
|
+
|
|
9
|
+
attr_reader :root, :rules, :paths, :exclude, :max_file_size
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def load(path = nil, dir: Dir.pwd)
|
|
13
|
+
path ||= locate(dir)
|
|
14
|
+
unless path
|
|
15
|
+
raise ConfigError,
|
|
16
|
+
"No config file found: looked for #{CONFIG_FILENAMES.join(", ")} in #{dir} and its parents"
|
|
17
|
+
end
|
|
18
|
+
raise ConfigError, "Config file not found: #{path}" unless File.file?(path)
|
|
19
|
+
|
|
20
|
+
path = File.expand_path(path)
|
|
21
|
+
new(parse(path), root: File.dirname(path))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Walks up from `dir` and returns the first config file found, like git does for .git.
|
|
25
|
+
def locate(dir)
|
|
26
|
+
Pathname(dir).expand_path.ascend do |ancestor|
|
|
27
|
+
CONFIG_FILENAMES.each do |name|
|
|
28
|
+
candidate = ancestor / name
|
|
29
|
+
return candidate.to_s if candidate.file?
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def parse(path)
|
|
38
|
+
YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: true) || {}
|
|
39
|
+
rescue Psych::SyntaxError => e
|
|
40
|
+
raise ConfigError, "#{path} is not valid YAML: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(data, root: Dir.pwd)
|
|
45
|
+
raise ConfigError, "config must be a map, got #{data.class}" unless data.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
data = data.transform_keys(&:to_s)
|
|
48
|
+
Schema.reject_unknown_keys!(data, KEYS, context: "config")
|
|
49
|
+
|
|
50
|
+
@root = File.expand_path(root)
|
|
51
|
+
@paths = Schema.string_list(data["paths"])
|
|
52
|
+
@exclude = Schema.string_list(data["exclude"])
|
|
53
|
+
@max_file_size = build_max_file_size(data.fetch("max_file_size", DEFAULT_MAX_FILE_SIZE))
|
|
54
|
+
@rules = build_rules(data["rules"])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The rules that apply to a repository-relative path, in config order.
|
|
58
|
+
def rules_for(path) = rules.select { |rule| rule.applies_to?(path) }
|
|
59
|
+
|
|
60
|
+
def rule(id) = rules.find { |rule| rule.id == id.to_s }
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def build_max_file_size(value)
|
|
65
|
+
raise ConfigError, "`max_file_size` must be a positive integer of bytes" unless value.is_a?(Integer) && value.positive?
|
|
66
|
+
|
|
67
|
+
value
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def build_rules(rules)
|
|
71
|
+
raise ConfigError, "`rules` must be a map of rule id => attributes" unless rules.is_a?(Hash)
|
|
72
|
+
raise ConfigError, "`rules` is empty: nothing to lint" if rules.empty?
|
|
73
|
+
|
|
74
|
+
rules.map { |id, attrs| Rule.new(id, attrs, default_paths: paths, default_exclude: exclude) }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|