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.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # Resolves which files a run should look at, always as paths relative to the
5
+ # config root with forward slashes, in a stable order.
6
+ class FileFinder
7
+ attr_reader :root, :git
8
+
9
+ def initialize(root, git: Git.new(root))
10
+ @root = root
11
+ @git = git
12
+ end
13
+
14
+ # Every file in the tree: git's view when inside a repository, else a walk.
15
+ def all = sources(files_under(root))
16
+
17
+ def changed_since(ref)
18
+ require_repository!
19
+ sources(git.changed_files_since(ref))
20
+ end
21
+
22
+ def staged
23
+ require_repository!
24
+ sources(git.staged_files, existing_only: false) { |path| git.staged_content(path) }
25
+ end
26
+
27
+ # Paths given on the command line, resolved against the current directory.
28
+ def explicit(paths, from: Dir.pwd)
29
+ sources(paths.flat_map { |path| files_under(File.expand_path(path, from)) })
30
+ end
31
+
32
+ private
33
+
34
+ def require_repository!
35
+ raise Error, "#{root} is not a git repository: --diff and --staged need git" unless git.repository?
36
+ end
37
+
38
+ # Repository-relative paths of the files at or beneath an absolute path.
39
+ def files_under(absolute)
40
+ return [relativize(absolute)] unless File.directory?(absolute)
41
+
42
+ relative = relativize(absolute)
43
+ return git.files(relative) if git.repository?
44
+
45
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: absolute)
46
+ .reject { |path| path == ".git" || path.start_with?(".git/") }
47
+ .map { |path| relative == "." ? path : File.join(relative, path) }
48
+ end
49
+
50
+ def sources(paths, existing_only: true, &reader)
51
+ reader ||= ->(path) { File.binread(File.join(root, path)) }
52
+
53
+ paths.sort.uniq.filter_map do |path|
54
+ next if existing_only && !File.file?(File.join(root, path))
55
+
56
+ SourceFile.new(path) { reader.call(path) }
57
+ end
58
+ end
59
+
60
+ def relativize(absolute)
61
+ relative = Pathname.new(absolute).relative_path_from(Pathname.new(root)).to_s
62
+ raise Error, "#{absolute} is outside the config root #{root}" if relative.start_with?("..")
63
+
64
+ relative
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ class Formatter
5
+ # GitHub Actions workflow commands: each offense becomes an annotation on
6
+ # its file in the pull request, then a plain summary for the job log.
7
+ class Github < Formatter
8
+ private
9
+
10
+ def offense_line(offense)
11
+ command(offense.severity, offense.message, file: offense.path, title: "lintus: #{offense.rule.id}")
12
+ end
13
+
14
+ def skipped_line(skipped)
15
+ command("notice", "Skipped: #{skipped.reason}", file: skipped.path, title: "lintus")
16
+ end
17
+
18
+ def failure_line(failure)
19
+ command("error", failure.error.message, file: failure.path, title: "lintus: request failed")
20
+ end
21
+
22
+ def command(level, message, **properties)
23
+ props = properties.map { |key, value| "#{key}=#{escape_property(value)}" }.join(",")
24
+ "::#{level} #{props}::#{escape_data(message)}"
25
+ end
26
+
27
+ def escape_data(value) = value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
28
+
29
+ def escape_property(value) = escape_data(value).gsub(":", "%3A").gsub(",", "%2C")
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Lintus
6
+ class Formatter
7
+ # Machine-readable output for other tools to consume.
8
+ class Json < Formatter
9
+ def render(report)
10
+ io.puts ::JSON.pretty_generate(
11
+ summary: {
12
+ files_inspected: report.checked.size,
13
+ offenses: report.offenses.size,
14
+ errors: report.errors.size,
15
+ warnings: report.warnings.size,
16
+ skipped: report.skipped.size,
17
+ failures: report.failures.size
18
+ },
19
+ offenses: report.sorted_offenses.map(&:to_h),
20
+ skipped: report.skipped.map { |skipped| { path: skipped.path, reason: skipped.reason } },
21
+ failures: report.failures.map { |failure| { path: failure.path, error: failure.error.message } }
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ class Formatter
5
+ # Human-readable output, one line per offense.
6
+ class Text < Formatter
7
+ private
8
+
9
+ def offense_line(offense)
10
+ "#{offense.path}: [#{offense.rule.id}] #{offense.message} (#{offense.severity}, noul #{offense.noul.round(2)})"
11
+ end
12
+
13
+ def skipped_line(skipped) = "#{skipped.path}: skipped, #{skipped.reason}"
14
+
15
+ def failure_line(failure) = "#{failure.path}: failed, #{failure.error.message}"
16
+
17
+ def before_summary(report)
18
+ io.puts if report.offenses.any? || report.skipped.any? || report.failures.any?
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # Renders a Report to an IO. Line-oriented subclasses override the three
5
+ # `*_line` hooks; anything else overrides `render`.
6
+ class Formatter
7
+ NAMES = %w[text github json].freeze
8
+
9
+ def self.for(name)
10
+ raise Error, "unknown format #{name.inspect} (expected one of #{NAMES.join(", ")})" unless NAMES.include?(name.to_s)
11
+
12
+ const_get(name.to_s.capitalize)
13
+ end
14
+
15
+ attr_reader :io
16
+
17
+ def initialize(io = $stdout)
18
+ @io = io
19
+ end
20
+
21
+ def render(report)
22
+ report.sorted_offenses.each { |offense| io.puts offense_line(offense) }
23
+ report.skipped.each { |skipped| io.puts skipped_line(skipped) }
24
+ report.failures.each { |failure| io.puts failure_line(failure) }
25
+ before_summary(report)
26
+ io.puts summary(report)
27
+ end
28
+
29
+ private
30
+
31
+ def offense_line(offense) = raise(NotImplementedError)
32
+ def skipped_line(skipped) = raise(NotImplementedError)
33
+ def failure_line(failure) = raise(NotImplementedError)
34
+
35
+ def before_summary(report); end
36
+
37
+ def summary(report)
38
+ parts = ["#{pluralize(report.checked.size, "file")} inspected",
39
+ "#{pluralize(report.offenses.size, "offense")} detected"]
40
+ parts << "#{pluralize(report.skipped.size, "file")} skipped" if report.skipped.any?
41
+ parts << "#{pluralize(report.failures.size, "file")} failed" if report.failures.any?
42
+ parts.join(", ")
43
+ end
44
+
45
+ def pluralize(count, noun) = "#{count} #{noun}#{"s" unless count == 1}"
46
+ end
47
+ end
data/lib/lintus/git.rb ADDED
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Lintus
6
+ # A thin wrapper around the git commands file discovery needs.
7
+ class Git
8
+ class CommandError < Error; end
9
+
10
+ CHANGE_FILTER = "--diff-filter=ACMR"
11
+
12
+ attr_reader :root
13
+
14
+ def initialize(root)
15
+ @root = root
16
+ end
17
+
18
+ def repository? = run("rev-parse", "--is-inside-work-tree", allow_failure: true) == "true"
19
+
20
+ # Tracked and untracked files, honouring .gitignore, optionally under the given directories.
21
+ def files(*pathspecs) = lines("ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", *pathspecs)
22
+
23
+ def staged_files = lines("diff", "-z", "--cached", "--name-only", CHANGE_FILTER)
24
+
25
+ # Files that differ between the working tree and the merge base of `ref` and HEAD,
26
+ # plus untracked files. With `ref` at HEAD this is "what I have not committed yet".
27
+ def changed_files_since(ref)
28
+ base = run("merge-base", ref, "HEAD", allow_failure: true) || ref
29
+ lines("diff", "-z", "--name-only", CHANGE_FILTER, base) + lines("ls-files", "-z", "--others", "--exclude-standard")
30
+ end
31
+
32
+ def staged_content(path) = run("show", ":#{path}", chomp: false)
33
+
34
+ private
35
+
36
+ def lines(*)
37
+ run(*, chomp: false).split("\0").reject(&:empty?)
38
+ end
39
+
40
+ def run(*args, allow_failure: false, chomp: true)
41
+ stdout, stderr, status = Open3.capture3("git", *args, chdir: root)
42
+
43
+ unless status.success?
44
+ return nil if allow_failure
45
+
46
+ raise CommandError, "git #{args.join(" ")} failed: #{stderr.strip}"
47
+ end
48
+
49
+ chomp ? stdout.chomp : stdout
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # Matches repository-relative paths against the globs used in the config file.
5
+ #
6
+ # Patterns follow Ruby's File.fnmatch with pathname semantics ("**/" spans
7
+ # directories, "{a,b}" alternation is allowed), with two conveniences:
8
+ # a bare directory ("vendor" or "vendor/") matches everything beneath it,
9
+ # and a trailing "**" ("vendor/**") does the same.
10
+ module Glob
11
+ FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
12
+
13
+ # Every pattern is expanded once, however many paths it is matched against.
14
+ EXPANSIONS = Hash.new { |cache, pattern| cache[pattern] = expand(pattern) }
15
+
16
+ module_function
17
+
18
+ def match?(pattern, path)
19
+ EXPANSIONS[pattern].any? { |expanded| File.fnmatch?(expanded, path, FLAGS) }
20
+ end
21
+
22
+ def match_any?(patterns, path)
23
+ patterns.any? { |pattern| match?(pattern, path) }
24
+ end
25
+
26
+ def expand(pattern)
27
+ pattern = pattern.to_s.delete_prefix("./").delete_suffix("/")
28
+
29
+ if pattern.end_with?("**")
30
+ [pattern, "#{pattern}/*"]
31
+ elsif pattern.match?(/[*?\[{]/)
32
+ [pattern]
33
+ else
34
+ [pattern, "#{pattern}/**/*"]
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # A rule the model flagged on a file, with the probability it gave.
5
+ Offense = Struct.new(:path, :rule, :noul, keyword_init: true) do
6
+ def severity = rule.severity
7
+ def error? = rule.error?
8
+ def message = rule.description
9
+
10
+ def to_h
11
+ { path: path, rule: rule.id, severity: severity, message: message, noul: noul.round(3) }
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # Everything a run produced: what was checked, what was flagged, what was skipped, what broke.
5
+ class Report
6
+ Skipped = Struct.new(:path, :reason, keyword_init: true)
7
+ Failure = Struct.new(:path, :error, keyword_init: true)
8
+
9
+ attr_reader :checked, :offenses, :skipped, :failures
10
+
11
+ def initialize
12
+ @checked = []
13
+ @offenses = []
14
+ @skipped = []
15
+ @failures = []
16
+ @mutex = Mutex.new
17
+ end
18
+
19
+ def record_checked(path, offenses)
20
+ synchronize do
21
+ checked << path
22
+ self.offenses.concat(offenses)
23
+ end
24
+ end
25
+
26
+ def record_skipped(path, reason)
27
+ synchronize { skipped << Skipped.new(path: path, reason: reason) }
28
+ end
29
+
30
+ def record_failure(path, error)
31
+ synchronize { failures << Failure.new(path: path, error: error) }
32
+ end
33
+
34
+ def errors = offenses.select(&:error?)
35
+ def warnings = offenses.reject(&:error?)
36
+
37
+ def sorted_offenses = offenses.sort_by { |offense| [offense.path, offense.rule.id] }
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")
41
+ return 2 if failures.any?
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
49
+ end
50
+
51
+ private
52
+
53
+ def synchronize(&) = @mutex.synchronize(&)
54
+ end
55
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # One entry of the `rules:` map in the config file.
5
+ #
6
+ # A rule is a noul question. It is asked against every file whose path
7
+ # matches its globs, and the file is an offense when the answer equals
8
+ # `offense_when` (true by default, so questions are phrased to describe the
9
+ # offense: "Does this file call sleep?").
10
+ class Rule
11
+ KEYS = %w[question description criteria threshold paths exclude severity offense_when].freeze
12
+ SEVERITIES = %w[error warning].freeze
13
+ ID_FORMAT = /\A[a-z][a-z0-9_]*\z/
14
+
15
+ attr_reader :id, :description, :question, :criteria, :threshold, :paths, :exclude, :severity, :offense_when
16
+
17
+ def initialize(id, attrs, default_paths: [], default_exclude: [])
18
+ @id = validate_id(id)
19
+ attrs = normalize(attrs)
20
+
21
+ @question = fetch_string(attrs, "question")
22
+ @description = attrs.fetch("description", @question).to_s
23
+ @criteria = build_criteria(attrs["criteria"])
24
+ @threshold = build_threshold(attrs["threshold"])
25
+ @paths = Schema.string_list(attrs.fetch("paths", default_paths))
26
+ @exclude = default_exclude + Schema.string_list(attrs["exclude"])
27
+ @severity = build_severity(attrs.fetch("severity", "error"))
28
+ @offense_when = build_offense_when(attrs.fetch("offense_when", true))
29
+ end
30
+
31
+ def applies_to?(path)
32
+ return false if Glob.match_any?(exclude, path)
33
+
34
+ paths.empty? || Glob.match_any?(paths, path)
35
+ end
36
+
37
+ def add_to(query)
38
+ options = { criteria: criteria, threshold: threshold }.compact
39
+ query.ask(id, question, **options)
40
+ end
41
+
42
+ def offense?(answer) = answer.result == offense_when
43
+
44
+ def error? = severity == "error"
45
+
46
+ private
47
+
48
+ def validate_id(id)
49
+ id = id.to_s
50
+ unless id.match?(ID_FORMAT)
51
+ raise ConfigError,
52
+ "invalid rule id #{id.inspect}: use snake_case (letters, digits, underscores)"
53
+ end
54
+
55
+ id
56
+ end
57
+
58
+ def normalize(attrs)
59
+ raise ConfigError, "rule #{id}: expected a map of attributes, got #{attrs.inspect}" unless attrs.is_a?(Hash)
60
+
61
+ attrs = attrs.transform_keys(&:to_s)
62
+ Schema.reject_unknown_keys!(attrs, KEYS, context: "rule #{id}")
63
+ attrs
64
+ end
65
+
66
+ def fetch_string(attrs, key)
67
+ value = attrs[key]
68
+ raise ConfigError, "rule #{id}: `#{key}` is required" if value.nil? || value.to_s.strip.empty?
69
+
70
+ value.to_s.strip
71
+ end
72
+
73
+ def build_criteria(criteria)
74
+ return nil if criteria.nil?
75
+ raise ConfigError, "rule #{id}: `criteria` must be a map with `true` and `false` keys" unless criteria.is_a?(Hash)
76
+
77
+ criteria.to_h { |key, value| [key.to_s, value.to_s] }
78
+ end
79
+
80
+ def build_threshold(threshold)
81
+ return nil if threshold.nil?
82
+
83
+ valid = threshold.is_a?(Numeric) && threshold.between?(0, 1)
84
+ raise ConfigError, "rule #{id}: `threshold` must be a number between 0 and 1" unless valid
85
+
86
+ threshold.to_f
87
+ end
88
+
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
+ def build_offense_when(value)
100
+ raise ConfigError, "rule #{id}: `offense_when` must be true or false" unless [true, false].include?(value)
101
+
102
+ value
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # Sends each file to Jev once, with every rule that applies to it as a
5
+ # separate noul question, and turns the answers into offenses.
6
+ class Runner
7
+ RETRYABLE = [Jev::RateLimitError, Jev::OverloadedError].freeze
8
+
9
+ attr_reader :config, :jobs, :retries
10
+
11
+ def initialize(config, jobs: 4, retries: 3, sleeper: Kernel.method(:sleep))
12
+ @config = config
13
+ @jobs = [jobs.to_i, 1].max
14
+ @retries = retries
15
+ @sleeper = sleeper
16
+ end
17
+
18
+ # Pairs every file with the rules that apply to it, dropping files no rule covers.
19
+ def plan(files)
20
+ files.filter_map do |file|
21
+ rules = config.rules_for(file.path)
22
+ [file, rules] unless rules.empty?
23
+ end
24
+ end
25
+
26
+ # Checks the [file, rules] pairs from #plan concurrently.
27
+ def run(tasks)
28
+ report = Report.new
29
+ queue = Queue.new
30
+ tasks.each { |task| queue << task }
31
+ queue.close
32
+
33
+ workers = Array.new([jobs, tasks.size].min) do
34
+ Thread.new do
35
+ while (task = queue.pop)
36
+ check(*task, report)
37
+ end
38
+ end
39
+ end
40
+ workers.each(&:join)
41
+
42
+ report
43
+ end
44
+
45
+ def check(file, rules, report)
46
+ content = file.content
47
+ if (reason = skip_reason(content))
48
+ report.record_skipped(file.path, reason)
49
+ return
50
+ end
51
+
52
+ response = with_retries { perform(file, rules, content) }
53
+ report.record_checked(file.path, offenses_for(file, rules, response.answers))
54
+ rescue Jev::Error, Error => e
55
+ report.record_failure(file.path, e)
56
+ end
57
+
58
+ private
59
+
60
+ def skip_reason(content)
61
+ return "binary file" if content.include?("\0") || !content.valid_encoding?
62
+ if content.bytesize > config.max_file_size
63
+ return "larger than max_file_size (#{content.bytesize} > #{config.max_file_size} bytes)"
64
+ end
65
+
66
+ nil
67
+ end
68
+
69
+ def perform(file, rules, content)
70
+ Jev::Query.new("File: #{file.path}\n\n#{content}").perform do |query|
71
+ rules.each { |rule| rule.add_to(query) }
72
+ end
73
+ end
74
+
75
+ def offenses_for(file, rules, answers)
76
+ rules.filter_map do |rule|
77
+ answer = answers[rule.id]
78
+ Offense.new(path: file.path, rule: rule, noul: answer.noul) if rule.offense?(answer)
79
+ end
80
+ end
81
+
82
+ def with_retries
83
+ attempt = 0
84
+ begin
85
+ yield
86
+ rescue *RETRYABLE
87
+ raise if attempt >= retries
88
+
89
+ attempt += 1
90
+ @sleeper.call(backoff_for(attempt))
91
+ retry
92
+ end
93
+ end
94
+
95
+ def backoff_for(attempt) = (2**attempt) * (0.5 + (rand / 2))
96
+ end
97
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # Small checks shared by everything that reads the config file.
5
+ module Schema
6
+ module_function
7
+
8
+ def reject_unknown_keys!(hash, allowed, context:)
9
+ unknown = hash.keys - allowed
10
+ return if unknown.empty?
11
+
12
+ raise ConfigError, "#{context}: unknown key(s) #{unknown.join(", ")} (expected #{allowed.join(", ")})"
13
+ end
14
+
15
+ def string_list(value) = Array(value).map(&:to_s)
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # A file to lint: a repository-relative path and a reader for its content.
5
+ # The reader is what lets `--staged` lint the index rather than the working tree.
6
+ class SourceFile
7
+ attr_reader :path
8
+
9
+ def initialize(path, &reader)
10
+ @path = path
11
+ @reader = reader
12
+ end
13
+
14
+ # Reads the content afresh on every call so a run holds only the files in flight.
15
+ def content
16
+ (+@reader.call).force_encoding(Encoding::UTF_8)
17
+ rescue SystemCallError, Git::CommandError => e
18
+ raise ReadError, "could not read #{path}: #{e.message}"
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ # The starter config written by `lintus init`.
5
+ module Template
6
+ CONFIG = <<~YAML
7
+ # Lintus configuration. Every rule is a question asked of the Jev model
8
+ # about each matching file. Answer "true" means the file is an offense.
9
+ #
10
+ # Top-level `paths` and `exclude` apply to every rule; a rule can narrow
11
+ # them with its own `paths` and `exclude`. Globs are relative to this file.
12
+
13
+ paths:
14
+ - "**/*.rb"
15
+
16
+ exclude:
17
+ - "vendor/**"
18
+ - "node_modules/**"
19
+ - "tmp/**"
20
+
21
+ # Files larger than this (in bytes) are skipped rather than sent to the model.
22
+ max_file_size: 100000
23
+
24
+ rules:
25
+ no_debugging_leftovers:
26
+ description: Debugging statements must not be committed.
27
+ question: Does this file contain leftover debugging code, such as binding.irb, debugger, byebug, or a puts/p/pp used for debugging?
28
+ criteria:
29
+ "true": A debugger breakpoint or a throwaway print statement is present.
30
+ "false": Any output is deliberate program behaviour, or there is none.
31
+ severity: error
32
+
33
+ no_hardcoded_secrets:
34
+ description: Secrets must come from the environment or a credentials store, never source code.
35
+ question: Does this file contain a hardcoded secret, such as an API key, password, or private token?
36
+ criteria:
37
+ "true": A literal credential value is written in the source.
38
+ "false": Credentials are read from configuration, or there are none.
39
+ threshold: 0.7
40
+ severity: error
41
+
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
48
+ YAML
49
+ end
50
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lintus
4
+ VERSION = "0.1.0"
5
+ end
data/lib/lintus.rb ADDED
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zeitwerk"
4
+ require "jev"
5
+ require "yaml"
6
+ require "pathname"
7
+ require_relative "lintus/version"
8
+
9
+ loader = Zeitwerk::Loader.for_gem
10
+ loader.inflector.inflect("cli" => "CLI")
11
+ loader.setup
12
+
13
+ # Lintus turns a YAML file of plain-language rules into a linter: every rule is
14
+ # asked to the Jev model as a noul question against each file it applies to, and
15
+ # an offense is reported wherever the answer says so.
16
+ module Lintus
17
+ class Error < StandardError; end
18
+ class ConfigError < Error; end
19
+ class ReadError < Error; end
20
+
21
+ CONFIG_FILENAMES = %w[.lintus.yml lintus.yml .lintus.yaml lintus.yaml].freeze
22
+ end