nomos 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/CHANGELOG.md +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +346 -0
- data/Rakefile +8 -0
- data/docs/logo-header.svg +40 -0
- data/examples/.nomos/rules.rb +9 -0
- data/examples/nomos.yml +30 -0
- data/exe/nomos +7 -0
- data/lib/nomos/cache.rb +39 -0
- data/lib/nomos/cli.rb +220 -0
- data/lib/nomos/config.rb +58 -0
- data/lib/nomos/context.rb +26 -0
- data/lib/nomos/context_loader.rb +66 -0
- data/lib/nomos/finding.rb +65 -0
- data/lib/nomos/github_client.rb +98 -0
- data/lib/nomos/reporters/console.rb +24 -0
- data/lib/nomos/reporters/github.rb +168 -0
- data/lib/nomos/reporters/json.rb +32 -0
- data/lib/nomos/rules/base.rb +18 -0
- data/lib/nomos/rules/builtin/forbid_paths.rb +32 -0
- data/lib/nomos/rules/builtin/no_large_pr.rb +28 -0
- data/lib/nomos/rules/builtin/require_file_change.rb +31 -0
- data/lib/nomos/rules/builtin/require_labels.rb +30 -0
- data/lib/nomos/rules/builtin/todo_guard.rb +32 -0
- data/lib/nomos/rules/ruby_dsl.rb +102 -0
- data/lib/nomos/rules/ruby_file.rb +19 -0
- data/lib/nomos/rules.rb +55 -0
- data/lib/nomos/runner.rb +67 -0
- data/lib/nomos/timing.rb +33 -0
- data/lib/nomos/version.rb +5 -0
- data/lib/nomos.rb +21 -0
- data/sig/nomos.rbs +4 -0
- data/site/assets/logo.png +0 -0
- data/site/assets/styles.css +375 -0
- data/site/content/index.md +7 -0
- data/site/craze.yml +19 -0
- data/site/templates/layouts/default.html.erb +20 -0
- data/site/templates/layouts/home.html.erb +198 -0
- metadata +85 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Nomos
|
|
6
|
+
module Reporters
|
|
7
|
+
class GitHub
|
|
8
|
+
MARKER = "<!-- nomos:report -->"
|
|
9
|
+
INLINE_MARKER = "<!-- nomos:inline -->"
|
|
10
|
+
INLINE_SEVERITIES = %i[warn fail].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(client:, repo:, pr_number:, pull_request: nil, context: nil)
|
|
13
|
+
@client = client
|
|
14
|
+
@repo = repo
|
|
15
|
+
@pr_number = pr_number
|
|
16
|
+
@pull_request = pull_request
|
|
17
|
+
@context = context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def report(findings)
|
|
21
|
+
body = build_body(findings)
|
|
22
|
+
existing = find_existing_comment
|
|
23
|
+
|
|
24
|
+
if existing
|
|
25
|
+
@client.update_comment(@repo, existing.fetch("id"), body)
|
|
26
|
+
else
|
|
27
|
+
@client.create_comment(@repo, @pr_number, body)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
create_inline_comments(findings)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def find_existing_comment
|
|
36
|
+
comments = @client.list_issue_comments(@repo, @pr_number)
|
|
37
|
+
comments.find { |comment| comment.fetch("body", "").include?(MARKER) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def build_body(findings)
|
|
41
|
+
lines = [MARKER, "## Nomos Report", ""]
|
|
42
|
+
|
|
43
|
+
if findings.empty?
|
|
44
|
+
lines << "No issues found."
|
|
45
|
+
else
|
|
46
|
+
blocks = findings.map { |finding| format_finding_block(finding) }
|
|
47
|
+
lines << blocks.join("\n\n")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
lines.join("\n")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def create_inline_comments(findings)
|
|
54
|
+
inline_findings = findings.select { |finding| INLINE_SEVERITIES.include?(finding.severity) }
|
|
55
|
+
return if inline_findings.empty?
|
|
56
|
+
|
|
57
|
+
comments = inline_findings.filter_map do |finding|
|
|
58
|
+
next unless finding.file && finding.line
|
|
59
|
+
|
|
60
|
+
diff_lines = diff_lines_for(finding.file)
|
|
61
|
+
next unless diff_lines.include?(finding.line)
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
path: finding.file,
|
|
65
|
+
line: finding.line,
|
|
66
|
+
side: "RIGHT",
|
|
67
|
+
body: build_inline_body(finding)
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
return if comments.empty?
|
|
72
|
+
|
|
73
|
+
pr = @pull_request || @context&.pull_request || @client.pull_request(@repo, @pr_number)
|
|
74
|
+
commit_id = pr.dig("head", "sha")
|
|
75
|
+
|
|
76
|
+
@client.create_review(
|
|
77
|
+
@repo,
|
|
78
|
+
@pr_number,
|
|
79
|
+
body: "Nomos inline review comments",
|
|
80
|
+
event: "COMMENT",
|
|
81
|
+
comments: comments,
|
|
82
|
+
commit_id: commit_id
|
|
83
|
+
)
|
|
84
|
+
rescue Nomos::Error => e
|
|
85
|
+
warn "Nomos inline review comment failed: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_inline_body(finding)
|
|
89
|
+
"#{INLINE_MARKER}\n#{format_alert_block(finding, include_location: false)}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def format_finding_block(finding)
|
|
93
|
+
format_alert_block(finding, include_location: true)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def format_alert_block(finding, include_location:)
|
|
97
|
+
alert = severity_alert(finding.severity)
|
|
98
|
+
location = if include_location && finding.file
|
|
99
|
+
" (`#{finding.file}#{finding.line ? ":#{finding.line}" : ""}`)"
|
|
100
|
+
else
|
|
101
|
+
""
|
|
102
|
+
end
|
|
103
|
+
source = finding.source.to_s.empty? ? "" : " (#{finding.source})"
|
|
104
|
+
text = "#{finding.text}#{source}#{location}"
|
|
105
|
+
"> [!#{alert}]\n> #{text}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def severity_alert(severity)
|
|
109
|
+
case severity
|
|
110
|
+
when :fail
|
|
111
|
+
"CAUTION"
|
|
112
|
+
when :warn
|
|
113
|
+
"WARNING"
|
|
114
|
+
else
|
|
115
|
+
"NOTE"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def diff_lines_for(file)
|
|
120
|
+
@diff_lines_by_file ||= {}
|
|
121
|
+
return @diff_lines_by_file[file] if @diff_lines_by_file.key?(file)
|
|
122
|
+
|
|
123
|
+
patch = patch_for(file)
|
|
124
|
+
@diff_lines_by_file[file] = right_side_lines(patch)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def patch_for(file)
|
|
128
|
+
return @context.diff(file) if @context
|
|
129
|
+
|
|
130
|
+
@pr_files ||= @client.pull_request_files(@repo, @pr_number)
|
|
131
|
+
@pr_files.find { |entry| entry["filename"] == file }&.fetch("patch", nil)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def right_side_lines(patch)
|
|
135
|
+
return Set.new unless patch
|
|
136
|
+
|
|
137
|
+
lines = Set.new
|
|
138
|
+
right_line = nil
|
|
139
|
+
|
|
140
|
+
patch.each_line do |line|
|
|
141
|
+
if line.start_with?("@@")
|
|
142
|
+
match = line.match(/\@\@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? \@\@/)
|
|
143
|
+
right_line = match ? match[1].to_i : nil
|
|
144
|
+
next
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
next unless right_line
|
|
148
|
+
|
|
149
|
+
case line[0]
|
|
150
|
+
when "+"
|
|
151
|
+
next if line.start_with?("+++")
|
|
152
|
+
lines << right_line
|
|
153
|
+
right_line += 1
|
|
154
|
+
when "-"
|
|
155
|
+
next if line.start_with?("---")
|
|
156
|
+
when " "
|
|
157
|
+
lines << right_line
|
|
158
|
+
right_line += 1
|
|
159
|
+
when "\\"
|
|
160
|
+
# no newline at end of file; ignore
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
lines
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Nomos
|
|
7
|
+
module Reporters
|
|
8
|
+
class Json
|
|
9
|
+
def initialize(path:)
|
|
10
|
+
@path = path
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def report(findings)
|
|
14
|
+
data = {
|
|
15
|
+
generated_at: Time.now.utc.iso8601,
|
|
16
|
+
counts: count_findings(findings),
|
|
17
|
+
findings: findings.map(&:to_h)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
File.write(@path, JSON.pretty_generate(data))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def count_findings(findings)
|
|
26
|
+
counts = Hash.new(0)
|
|
27
|
+
findings.each { |finding| counts[finding.severity] += 1 }
|
|
28
|
+
counts
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nomos
|
|
4
|
+
module Rules
|
|
5
|
+
class Base
|
|
6
|
+
attr_reader :name, :params
|
|
7
|
+
|
|
8
|
+
def initialize(name:, params: {})
|
|
9
|
+
@name = name
|
|
10
|
+
@params = params
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(_context)
|
|
14
|
+
raise NotImplementedError, "Rules must implement #run"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base"
|
|
4
|
+
require_relative "../../finding"
|
|
5
|
+
|
|
6
|
+
module Nomos
|
|
7
|
+
module Rules
|
|
8
|
+
module Builtin
|
|
9
|
+
class ForbidPaths < Base
|
|
10
|
+
def run(context)
|
|
11
|
+
patterns = Array(params[:patterns])
|
|
12
|
+
return [] if patterns.empty?
|
|
13
|
+
|
|
14
|
+
matches = context.changed_files.select do |file|
|
|
15
|
+
patterns.any? { |pattern| File.fnmatch?(pattern, file) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return [] if matches.empty?
|
|
19
|
+
|
|
20
|
+
message = [
|
|
21
|
+
"Restricted paths changed",
|
|
22
|
+
"- Files: #{matches.join(", ")}",
|
|
23
|
+
"- Impact: Changes in protected paths require extra review.",
|
|
24
|
+
"- Action: Revert these changes or update the rule."
|
|
25
|
+
].join("\n")
|
|
26
|
+
|
|
27
|
+
[Finding.fail(message, source: name)]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base"
|
|
4
|
+
require_relative "../../finding"
|
|
5
|
+
|
|
6
|
+
module Nomos
|
|
7
|
+
module Rules
|
|
8
|
+
module Builtin
|
|
9
|
+
class NoLargePr < Base
|
|
10
|
+
def run(context)
|
|
11
|
+
max = params.fetch(:max_changed_lines, 0)
|
|
12
|
+
return [] if max <= 0
|
|
13
|
+
|
|
14
|
+
return [] if context.changed_lines <= max
|
|
15
|
+
|
|
16
|
+
message = [
|
|
17
|
+
"PR size exceeds limit",
|
|
18
|
+
"- Reason: #{context.changed_lines} lines changed (max #{max}).",
|
|
19
|
+
"- Impact: Large PRs are harder to review and riskier to merge.",
|
|
20
|
+
"- Action: Split this PR or raise the limit."
|
|
21
|
+
].join("\n")
|
|
22
|
+
|
|
23
|
+
[Finding.fail(message, source: name)]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base"
|
|
4
|
+
require_relative "../../finding"
|
|
5
|
+
|
|
6
|
+
module Nomos
|
|
7
|
+
module Rules
|
|
8
|
+
module Builtin
|
|
9
|
+
class RequireFileChange < Base
|
|
10
|
+
def run(context)
|
|
11
|
+
patterns = Array(params[:patterns])
|
|
12
|
+
return [] if patterns.empty?
|
|
13
|
+
|
|
14
|
+
matched = patterns.any? do |pattern|
|
|
15
|
+
context.changed_files.any? { |file| File.fnmatch?(pattern, file) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
return [] if matched
|
|
19
|
+
|
|
20
|
+
message = [
|
|
21
|
+
"Required file update missing",
|
|
22
|
+
"- Reason: None of these patterns were changed: #{patterns.join(", ")}.",
|
|
23
|
+
"- Action: Update at least one matching file or adjust the rule."
|
|
24
|
+
].join("\n")
|
|
25
|
+
|
|
26
|
+
[Finding.fail(message, source: name)]
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base"
|
|
4
|
+
require_relative "../../finding"
|
|
5
|
+
|
|
6
|
+
module Nomos
|
|
7
|
+
module Rules
|
|
8
|
+
module Builtin
|
|
9
|
+
class RequireLabels < Base
|
|
10
|
+
def run(context)
|
|
11
|
+
required = Array(params[:labels]).map(&:to_s).reject(&:empty?)
|
|
12
|
+
return [] if required.empty?
|
|
13
|
+
|
|
14
|
+
labels = Array(context.pull_request["labels"]).map { |label| label["name"] }.compact
|
|
15
|
+
missing = required - labels
|
|
16
|
+
|
|
17
|
+
return [] if missing.empty?
|
|
18
|
+
|
|
19
|
+
message = [
|
|
20
|
+
"Missing required labels",
|
|
21
|
+
"- Missing: #{missing.join(", ")}",
|
|
22
|
+
"- Action: Add the label(s) to the PR."
|
|
23
|
+
].join("\n")
|
|
24
|
+
|
|
25
|
+
[Finding.fail(message, source: name)]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../base"
|
|
4
|
+
require_relative "../../finding"
|
|
5
|
+
|
|
6
|
+
module Nomos
|
|
7
|
+
module Rules
|
|
8
|
+
module Builtin
|
|
9
|
+
class TodoGuard < Base
|
|
10
|
+
def run(context)
|
|
11
|
+
patterns = Array(params[:patterns])
|
|
12
|
+
patterns = ["TODO"] if patterns.empty?
|
|
13
|
+
|
|
14
|
+
matches = context.changed_files.select do |file|
|
|
15
|
+
diff = context.diff(file).to_s
|
|
16
|
+
patterns.any? { |pattern| diff.include?(pattern) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
return [] if matches.empty?
|
|
20
|
+
|
|
21
|
+
message = [
|
|
22
|
+
"TODO markers in diff",
|
|
23
|
+
"- Files: #{matches.join(", ")}",
|
|
24
|
+
"- Action: Resolve or remove TODOs, or update the patterns."
|
|
25
|
+
].join("\n")
|
|
26
|
+
|
|
27
|
+
[Finding.fail(message, source: name)]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../finding"
|
|
4
|
+
|
|
5
|
+
module Nomos
|
|
6
|
+
module Rules
|
|
7
|
+
module RubyDSL
|
|
8
|
+
class RuleContext
|
|
9
|
+
def initialize(context, rule_name)
|
|
10
|
+
@context = context
|
|
11
|
+
@rule_name = rule_name
|
|
12
|
+
@findings = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def changed_files
|
|
16
|
+
@context.changed_files
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def diff(file)
|
|
20
|
+
@context.diff(file).to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def pr_title
|
|
24
|
+
@context.pull_request["title"]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def pr_body
|
|
28
|
+
@context.pull_request["body"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pr_number
|
|
32
|
+
@context.pull_request["number"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def pr_author
|
|
36
|
+
@context.pull_request.dig("user", "login")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pr_labels
|
|
40
|
+
Array(@context.pull_request["labels"]).map { |label| label["name"] }.compact
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def repo
|
|
44
|
+
@context.repo
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def base_branch
|
|
48
|
+
@context.base_branch
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ci
|
|
52
|
+
@context.ci
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def message(text, **opts)
|
|
56
|
+
@findings << Finding.message(text, **opts, source: @rule_name)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def warn(text, **opts)
|
|
60
|
+
@findings << Finding.warn(text, **opts, source: @rule_name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def fail(text, **opts)
|
|
64
|
+
@findings << Finding.fail(text, **opts, source: @rule_name)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def findings
|
|
68
|
+
@findings.dup
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
class RuleDefinition
|
|
73
|
+
def initialize(name, block)
|
|
74
|
+
@name = name
|
|
75
|
+
@block = block
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def run(context)
|
|
79
|
+
rule_context = RuleContext.new(context, @name)
|
|
80
|
+
rule_context.instance_eval(&@block)
|
|
81
|
+
rule_context.findings
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
class RuleSet
|
|
86
|
+
def initialize
|
|
87
|
+
@rules = []
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def rule(name, &block)
|
|
91
|
+
raise ArgumentError, "Rule name is required" if name.to_s.empty?
|
|
92
|
+
|
|
93
|
+
@rules << RuleDefinition.new(name, block)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def run(context)
|
|
97
|
+
@rules.flat_map { |rule| rule.run(context) }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "ruby_dsl"
|
|
5
|
+
|
|
6
|
+
module Nomos
|
|
7
|
+
module Rules
|
|
8
|
+
class RubyFile < Base
|
|
9
|
+
def run(context)
|
|
10
|
+
path = params.fetch(:path)
|
|
11
|
+
raise Nomos::Error, "Rule file not found: #{path}" unless File.exist?(path)
|
|
12
|
+
|
|
13
|
+
ruleset = RubyDSL::RuleSet.new
|
|
14
|
+
ruleset.instance_eval(File.read(path), path)
|
|
15
|
+
ruleset.run(context)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/nomos/rules.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "finding"
|
|
4
|
+
require_relative "rules/base"
|
|
5
|
+
require_relative "rules/builtin/no_large_pr"
|
|
6
|
+
require_relative "rules/builtin/require_file_change"
|
|
7
|
+
require_relative "rules/builtin/forbid_paths"
|
|
8
|
+
require_relative "rules/builtin/require_labels"
|
|
9
|
+
require_relative "rules/builtin/todo_guard"
|
|
10
|
+
require_relative "rules/ruby_file"
|
|
11
|
+
|
|
12
|
+
module Nomos
|
|
13
|
+
module Rules
|
|
14
|
+
class LevelOverride
|
|
15
|
+
attr_reader :name
|
|
16
|
+
|
|
17
|
+
def initialize(rule, severity)
|
|
18
|
+
@rule = rule
|
|
19
|
+
@severity = severity
|
|
20
|
+
@name = rule.name
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(context)
|
|
24
|
+
Array(@rule.run(context)).map { |finding| finding.with_severity(@severity) }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
BUILTIN_MAP = {
|
|
29
|
+
"builtin.no_large_pr" => Builtin::NoLargePr,
|
|
30
|
+
"builtin.require_file_change" => Builtin::RequireFileChange,
|
|
31
|
+
"builtin.forbid_paths" => Builtin::ForbidPaths,
|
|
32
|
+
"builtin.require_labels" => Builtin::RequireLabels,
|
|
33
|
+
"builtin.todo_guard" => Builtin::TodoGuard,
|
|
34
|
+
"ruby.file" => RubyFile
|
|
35
|
+
}.freeze
|
|
36
|
+
|
|
37
|
+
def self.build(rule_config)
|
|
38
|
+
name = rule_config.fetch(:name)
|
|
39
|
+
type = rule_config.fetch(:type)
|
|
40
|
+
params = rule_config.fetch(:params, {})
|
|
41
|
+
level = rule_config[:level]
|
|
42
|
+
|
|
43
|
+
klass = BUILTIN_MAP[type]
|
|
44
|
+
raise Nomos::Error, "Unknown rule type: #{type}" unless klass
|
|
45
|
+
|
|
46
|
+
rule = klass.new(name: name, params: params)
|
|
47
|
+
return rule unless level
|
|
48
|
+
|
|
49
|
+
severity = Finding.severity_for_level(level)
|
|
50
|
+
LevelOverride.new(rule, severity)
|
|
51
|
+
rescue ArgumentError => e
|
|
52
|
+
raise Nomos::Error, e.message
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
data/lib/nomos/runner.rb
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "finding"
|
|
4
|
+
require_relative "rules"
|
|
5
|
+
|
|
6
|
+
module Nomos
|
|
7
|
+
class Runner
|
|
8
|
+
def initialize(config, context)
|
|
9
|
+
@config = config
|
|
10
|
+
@context = context
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
rules = @config.rules.map { |rule_config| Rules.build(rule_config) }
|
|
15
|
+
return [] if rules.empty?
|
|
16
|
+
|
|
17
|
+
concurrency = @config.performance.fetch(:concurrency, 1).to_i
|
|
18
|
+
return run_sequential(rules) if concurrency <= 1 || rules.length == 1
|
|
19
|
+
|
|
20
|
+
run_parallel(rules, concurrency)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def run_sequential(rules)
|
|
26
|
+
findings = []
|
|
27
|
+
|
|
28
|
+
rules.each do |rule|
|
|
29
|
+
begin
|
|
30
|
+
findings.concat(Array(rule.run(@context)))
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
findings << Finding.fail("Rule #{rule.name} failed: #{e.message}", source: rule.name)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
findings
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def run_parallel(rules, concurrency)
|
|
40
|
+
queue = Queue.new
|
|
41
|
+
rules.each { |rule| queue << rule }
|
|
42
|
+
|
|
43
|
+
findings = []
|
|
44
|
+
mutex = Mutex.new
|
|
45
|
+
threads = Array.new([concurrency, rules.length].min) do
|
|
46
|
+
Thread.new do
|
|
47
|
+
loop do
|
|
48
|
+
rule = queue.pop(true)
|
|
49
|
+
begin
|
|
50
|
+
result = Array(rule.run(@context))
|
|
51
|
+
mutex.synchronize { findings.concat(result) }
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
mutex.synchronize do
|
|
54
|
+
findings << Finding.fail("Rule #{rule.name} failed: #{e.message}", source: rule.name)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
rescue ThreadError
|
|
59
|
+
# queue empty
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
threads.each(&:join)
|
|
64
|
+
findings
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/nomos/timing.rb
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Nomos
|
|
4
|
+
class Timing
|
|
5
|
+
Entry = Struct.new(:label, :duration_ms, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@entries = []
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def measure(label)
|
|
12
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
13
|
+
result = yield
|
|
14
|
+
finish = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
15
|
+
duration_ms = ((finish - start) * 1000).round(2)
|
|
16
|
+
@entries << Entry.new(label: label, duration_ms: duration_ms)
|
|
17
|
+
result
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def entries
|
|
21
|
+
@entries.dup
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def report(io = $stderr)
|
|
25
|
+
return if @entries.empty?
|
|
26
|
+
|
|
27
|
+
io.puts "Nomos timing (ms):"
|
|
28
|
+
@entries.each do |entry|
|
|
29
|
+
io.puts "- #{entry.label}: #{entry.duration_ms}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
data/lib/nomos.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "nomos/version"
|
|
4
|
+
require_relative "nomos/cli"
|
|
5
|
+
require_relative "nomos/cache"
|
|
6
|
+
require_relative "nomos/config"
|
|
7
|
+
require_relative "nomos/context"
|
|
8
|
+
require_relative "nomos/context_loader"
|
|
9
|
+
require_relative "nomos/finding"
|
|
10
|
+
require_relative "nomos/github_client"
|
|
11
|
+
require_relative "nomos/rules"
|
|
12
|
+
require_relative "nomos/rules/ruby_file"
|
|
13
|
+
require_relative "nomos/runner"
|
|
14
|
+
require_relative "nomos/reporters/console"
|
|
15
|
+
require_relative "nomos/reporters/github"
|
|
16
|
+
require_relative "nomos/reporters/json"
|
|
17
|
+
require_relative "nomos/timing"
|
|
18
|
+
|
|
19
|
+
module Nomos
|
|
20
|
+
class Error < StandardError; end
|
|
21
|
+
end
|
data/sig/nomos.rbs
ADDED
|
Binary file
|