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 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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "lintus"
5
+
6
+ exit Lintus::CLI.new.run(ARGV)
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