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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nomos
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,4 @@
1
+ module Nomos
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
Binary file