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
|
@@ -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
|
data/lib/lintus/glob.rb
ADDED
|
@@ -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
|
data/lib/lintus/rule.rb
ADDED
|
@@ -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
|
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
|