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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ rule "no_debugger" do
4
+ changed_files.grep(/\.rb$/).each do |file|
5
+ if diff(file).include?("binding.pry")
6
+ fail "binding.pry detected", file: file
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,30 @@
1
+ version: 1
2
+
3
+ reporter:
4
+ github: true
5
+ console: true
6
+ json:
7
+ path: nomos-report.json
8
+
9
+ performance:
10
+ concurrency: 4
11
+ cache: true
12
+ lazy_diff: true
13
+ timing: false
14
+
15
+ rules:
16
+ - name: no_large_pr
17
+ type: builtin.no_large_pr
18
+ params:
19
+ max_changed_lines: 800
20
+
21
+ - name: require_changelog
22
+ type: builtin.require_file_change
23
+ params:
24
+ patterns:
25
+ - CHANGELOG.md
26
+
27
+ - name: custom_rules
28
+ type: ruby.file
29
+ params:
30
+ path: .nomos/rules.rb
data/exe/nomos ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "nomos"
6
+
7
+ exit Nomos::CLI.run(ARGV)
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Nomos
6
+ class Cache
7
+ def initialize(path:, enabled: true)
8
+ @path = path
9
+ @enabled = enabled
10
+ @data = enabled ? load_data : {}
11
+ end
12
+
13
+ def fetch(key)
14
+ return yield unless @enabled
15
+ return @data[key] if @data.key?(key)
16
+
17
+ value = yield
18
+ @data[key] = value
19
+ persist
20
+ value
21
+ end
22
+
23
+ private
24
+
25
+ def load_data
26
+ return {} unless File.exist?(@path)
27
+
28
+ JSON.parse(File.read(@path))
29
+ rescue JSON::ParserError
30
+ {}
31
+ end
32
+
33
+ def persist
34
+ dir = File.dirname(@path)
35
+ Dir.mkdir(dir) unless Dir.exist?(dir)
36
+ File.write(@path, JSON.pretty_generate(@data))
37
+ end
38
+ end
39
+ end
data/lib/nomos/cli.rb ADDED
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "config"
6
+ require_relative "context_loader"
7
+ require_relative "github_client"
8
+ require_relative "runner"
9
+ require_relative "reporters/console"
10
+ require_relative "reporters/github"
11
+ require_relative "timing"
12
+
13
+ module Nomos
14
+ class CLI
15
+ def self.run(argv)
16
+ command = argv.shift || "run"
17
+
18
+ case command
19
+ when "run"
20
+ new.run(argv)
21
+ when "init"
22
+ new.init(argv)
23
+ when "doctor"
24
+ new.doctor(argv)
25
+ else
26
+ warn "Unknown command: #{command}"
27
+ 1
28
+ end
29
+ end
30
+
31
+ def run(argv)
32
+ options = {
33
+ config: Config::DEFAULT_PATH,
34
+ strict: false,
35
+ debug: false,
36
+ reporter: nil,
37
+ no_cache: false
38
+ }
39
+
40
+ parser = OptionParser.new do |opts|
41
+ opts.banner = "Usage: nomos run [options]"
42
+ opts.on("--config PATH", "Config path (default: nomos.yml)") { |path| options[:config] = path }
43
+ opts.on("--strict", "Treat warns as failures") { options[:strict] = true }
44
+ opts.on("--debug", "Show debug details") { options[:debug] = true }
45
+ opts.on("--no-cache", "Disable cache and lazy diff for this run") { options[:no_cache] = true }
46
+ opts.on("--reporter LIST", "Comma-separated reporters (github,console,json)") do |list|
47
+ options[:reporter] = list.split(",").map(&:strip)
48
+ end
49
+ end
50
+
51
+ parser.parse!(argv)
52
+
53
+ timing = Timing.new
54
+ config = timing.measure("config") { Config.load(options[:config]) }
55
+ performance = config.performance
56
+ if options[:no_cache]
57
+ performance = performance.merge(cache: false, lazy_diff: false)
58
+ end
59
+ context = timing.measure("context") { ContextLoader.load(performance: performance) }
60
+ findings = timing.measure("rules") { Runner.new(config, context).run }
61
+ findings = upgrade_warnings(findings) if options[:strict]
62
+
63
+ reporters = timing.measure("reporters") { build_reporters(config, context, options[:reporter]) }
64
+ timing.measure("report_outputs") { reporters.each { |reporter| reporter.report(findings) } }
65
+
66
+ timing.report if config.performance.fetch(:timing, false)
67
+
68
+ exit_code(findings, options[:strict])
69
+ rescue Nomos::Error => e
70
+ warn e.message
71
+ raise if options[:debug]
72
+
73
+ 1
74
+ end
75
+
76
+ def init(_argv)
77
+ config_path = Config::DEFAULT_PATH
78
+ rules_path = ".nomos/rules.rb"
79
+
80
+ if File.exist?(config_path)
81
+ warn "#{config_path} already exists"
82
+ else
83
+ File.write(config_path, default_config)
84
+ puts "Created #{config_path}"
85
+ end
86
+
87
+ unless File.exist?(File.dirname(rules_path))
88
+ Dir.mkdir(File.dirname(rules_path))
89
+ end
90
+
91
+ if File.exist?(rules_path)
92
+ warn "#{rules_path} already exists"
93
+ else
94
+ File.write(rules_path, default_rules)
95
+ puts "Created #{rules_path}"
96
+ end
97
+
98
+ 0
99
+ end
100
+
101
+ def doctor(_argv)
102
+ config_path = Config::DEFAULT_PATH
103
+ ok = true
104
+
105
+ unless File.exist?(config_path)
106
+ warn "Missing config: #{config_path}"
107
+ ok = false
108
+ end
109
+
110
+ if ENV["GITHUB_TOKEN"].to_s.empty?
111
+ warn "Missing GITHUB_TOKEN"
112
+ ok = false
113
+ end
114
+
115
+ if ENV["GITHUB_EVENT_PATH"].to_s.empty? && (ENV["NOMOS_PR_NUMBER"].to_s.empty? || ENV["NOMOS_REPOSITORY"].to_s.empty?)
116
+ warn "Missing PR context (GITHUB_EVENT_PATH or NOMOS_PR_NUMBER/NOMOS_REPOSITORY)"
117
+ ok = false
118
+ end
119
+
120
+ puts ok ? "Nomos doctor: OK" : "Nomos doctor: issues found"
121
+ ok ? 0 : 1
122
+ end
123
+
124
+ private
125
+
126
+ def build_reporters(config, context, override)
127
+ enabled = if override
128
+ override.map(&:downcase)
129
+ else
130
+ config.reporters.map { |name, value| name.to_s if value }.compact
131
+ end
132
+
133
+ enabled = ["console"] if enabled.empty?
134
+
135
+ enabled.map do |name|
136
+ case name
137
+ when "console"
138
+ Reporters::Console.new
139
+ when "github"
140
+ client = GitHubClient.new(token: ENV["GITHUB_TOKEN"], api_url: ENV["GITHUB_API_URL"] || "https://api.github.com")
141
+ Reporters::GitHub.new(
142
+ client: client,
143
+ repo: context.repo,
144
+ pr_number: context.pull_request.fetch("number"),
145
+ pull_request: context.pull_request,
146
+ context: context
147
+ )
148
+ when "json"
149
+ Reporters::Json.new(path: json_report_path(config))
150
+ else
151
+ raise Nomos::Error, "Unknown reporter: #{name}"
152
+ end
153
+ end
154
+ end
155
+
156
+ def exit_code(findings, strict)
157
+ return 1 if findings.any? { |finding| finding.severity == :fail }
158
+
159
+ 0
160
+ end
161
+
162
+ def upgrade_warnings(findings)
163
+ findings.map do |finding|
164
+ finding.severity == :warn ? finding.with_severity(:fail) : finding
165
+ end
166
+ end
167
+
168
+ def default_config
169
+ <<~YAML
170
+ version: 1
171
+
172
+ reporter:
173
+ github: true
174
+ console: true
175
+ json:
176
+ path: nomos-report.json
177
+
178
+ performance:
179
+ concurrency: 4
180
+ cache: true
181
+ lazy_diff: true
182
+ timing: false
183
+
184
+ rules:
185
+ - name: no_large_pr
186
+ type: builtin.no_large_pr
187
+ params:
188
+ max_changed_lines: 800
189
+
190
+ - name: require_changelog
191
+ type: builtin.require_file_change
192
+ params:
193
+ patterns:
194
+ - CHANGELOG.md
195
+ YAML
196
+ end
197
+
198
+ def default_rules
199
+ <<~RUBY
200
+ # frozen_string_literal: true
201
+
202
+ # Custom rules will be supported in Phase 2.
203
+ RUBY
204
+ end
205
+
206
+ def json_report_path(config)
207
+ json = config.reporters[:json]
208
+ case json
209
+ when Hash
210
+ json[:path] || "nomos-report.json"
211
+ when String
212
+ json
213
+ when true
214
+ "nomos-report.json"
215
+ else
216
+ "nomos-report.json"
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Nomos
6
+ class Config
7
+ DEFAULT_PATH = "nomos.yml"
8
+
9
+ attr_reader :path, :data
10
+
11
+ def self.load(path = DEFAULT_PATH)
12
+ raw = read_yaml(path)
13
+ new(path, raw)
14
+ end
15
+
16
+ def initialize(path, raw)
17
+ @path = path
18
+ @data = symbolize_keys(raw || {})
19
+ end
20
+
21
+ def reporters
22
+ data.fetch(:reporter, {})
23
+ end
24
+
25
+ def performance
26
+ data.fetch(:performance, {})
27
+ end
28
+
29
+ def rules
30
+ Array(data[:rules])
31
+ end
32
+
33
+ private
34
+
35
+ def self.read_yaml(path)
36
+ unless File.exist?(path)
37
+ raise Nomos::Error, "Config not found: #{path}"
38
+ end
39
+
40
+ YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
41
+ rescue Psych::SyntaxError => e
42
+ raise Nomos::Error, "Invalid YAML in #{path}: #{e.message}"
43
+ end
44
+
45
+ def symbolize_keys(obj)
46
+ case obj
47
+ when Hash
48
+ obj.each_with_object({}) do |(key, value), memo|
49
+ memo[key.to_sym] = symbolize_keys(value)
50
+ end
51
+ when Array
52
+ obj.map { |value| symbolize_keys(value) }
53
+ else
54
+ obj
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nomos
4
+ class Context
5
+ attr_reader :pull_request, :changed_files, :repo, :base_branch, :ci, :changed_lines
6
+
7
+ def initialize(pull_request:, changed_files:, patches:, repo:, base_branch:, ci:, changed_lines:, patch_fetcher: nil)
8
+ @pull_request = pull_request
9
+ @changed_files = changed_files
10
+ @patches = patches
11
+ @patch_fetcher = patch_fetcher
12
+ @repo = repo
13
+ @base_branch = base_branch
14
+ @ci = ci
15
+ @changed_lines = changed_lines
16
+ freeze
17
+ end
18
+
19
+ def diff(file)
20
+ return @patches[file] if @patches.key?(file)
21
+ return unless @patch_fetcher
22
+
23
+ @patches[file] = @patch_fetcher.call(file)
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "cache"
6
+ require_relative "context"
7
+ require_relative "github_client"
8
+
9
+ module Nomos
10
+ class ContextLoader
11
+ def self.load(env: ENV, performance: {})
12
+ event = read_event(env["GITHUB_EVENT_PATH"])
13
+ pr_number = event.dig("pull_request", "number") || event["number"] || env["NOMOS_PR_NUMBER"]
14
+ repo = env["GITHUB_REPOSITORY"] || env["NOMOS_REPOSITORY"]
15
+
16
+ unless pr_number && repo
17
+ raise Nomos::Error, "Missing PR context. Set GITHUB_EVENT_PATH/GITHUB_REPOSITORY or NOMOS_PR_NUMBER/NOMOS_REPOSITORY."
18
+ end
19
+
20
+ token = env["GITHUB_TOKEN"]
21
+ api_url = env["GITHUB_API_URL"] || "https://api.github.com"
22
+
23
+ client = GitHubClient.new(token: token, api_url: api_url)
24
+
25
+ cache_enabled = performance.fetch(:cache, false)
26
+ cache_path = performance.fetch(:cache_path, ".nomos/cache.json")
27
+ cache = Cache.new(path: cache_path, enabled: cache_enabled)
28
+
29
+ pr_cache_key = "pr:#{repo}##{pr_number}"
30
+ files_cache_key = "files:#{repo}##{pr_number}"
31
+
32
+ pr = cache.fetch(pr_cache_key) { client.pull_request(repo, pr_number) }
33
+ files = cache.fetch(files_cache_key) { client.pull_request_files(repo, pr_number) }
34
+
35
+ changed_files = files.map { |file| file.fetch("filename") }
36
+ lazy_diff = performance.fetch(:lazy_diff, false)
37
+ patches = lazy_diff ? {} : files.each_with_object({}) { |file, memo| memo[file["filename"]] = file["patch"] }
38
+ patch_fetcher = lazy_diff ? lambda { |filename| files.find { |file| file["filename"] == filename }&.fetch("patch", nil) } : nil
39
+ changed_lines = files.sum { |file| file.fetch("additions", 0) + file.fetch("deletions", 0) }
40
+
41
+ Context.new(
42
+ pull_request: pr,
43
+ changed_files: changed_files,
44
+ patches: patches,
45
+ patch_fetcher: patch_fetcher,
46
+ repo: repo,
47
+ base_branch: pr.dig("base", "ref"),
48
+ ci: {
49
+ "workflow" => env["GITHUB_WORKFLOW"],
50
+ "run_id" => env["GITHUB_RUN_ID"],
51
+ "actor" => env["GITHUB_ACTOR"]
52
+ },
53
+ changed_lines: changed_lines
54
+ )
55
+ end
56
+
57
+ def self.read_event(path)
58
+ return {} unless path && File.exist?(path)
59
+
60
+ JSON.parse(File.read(path))
61
+ rescue JSON::ParserError => e
62
+ raise Nomos::Error, "Invalid event JSON at #{path}: #{e.message}"
63
+ end
64
+ private_class_method :read_event
65
+ end
66
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nomos
4
+ class Finding
5
+ SEVERITIES = %i[message warn fail].freeze
6
+ LEVEL_MAP = {
7
+ "note" => :message,
8
+ "warning" => :warn,
9
+ "caution" => :fail
10
+ }.freeze
11
+
12
+ attr_reader :severity, :text, :file, :line, :code, :source
13
+
14
+ def self.message(text, **opts)
15
+ new(:message, text, **opts)
16
+ end
17
+
18
+ def self.warn(text, **opts)
19
+ new(:warn, text, **opts)
20
+ end
21
+
22
+ def self.fail(text, **opts)
23
+ new(:fail, text, **opts)
24
+ end
25
+
26
+ def initialize(severity, text, file: nil, line: nil, code: nil, source: "")
27
+ unless SEVERITIES.include?(severity)
28
+ raise ArgumentError, "Unknown severity: #{severity}"
29
+ end
30
+
31
+ @severity = severity
32
+ @text = text
33
+ @file = file
34
+ @line = line
35
+ @code = code
36
+ @source = source
37
+ freeze
38
+ end
39
+
40
+ def self.severity_for_level(level)
41
+ key = level.to_s.strip.downcase
42
+ severity = LEVEL_MAP[key]
43
+ raise ArgumentError, "Unknown level: #{level}" unless severity
44
+
45
+ severity
46
+ end
47
+
48
+ def with_severity(new_severity)
49
+ return self if new_severity == severity
50
+
51
+ self.class.new(new_severity, text, file: file, line: line, code: code, source: source)
52
+ end
53
+
54
+ def to_h
55
+ {
56
+ severity: severity,
57
+ text: text,
58
+ file: file,
59
+ line: line,
60
+ code: code,
61
+ source: source
62
+ }
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module Nomos
8
+ class GitHubClient
9
+ def initialize(token:, api_url:)
10
+ @token = token
11
+ @api_url = api_url
12
+ end
13
+
14
+ def pull_request(repo, number)
15
+ get_json("/repos/#{repo}/pulls/#{number}")
16
+ end
17
+
18
+ def pull_request_files(repo, number)
19
+ get_paginated("/repos/#{repo}/pulls/#{number}/files")
20
+ end
21
+
22
+ def list_issue_comments(repo, number)
23
+ get_paginated("/repos/#{repo}/issues/#{number}/comments")
24
+ end
25
+
26
+ def create_comment(repo, number, body)
27
+ post_json("/repos/#{repo}/issues/#{number}/comments", body: body)
28
+ end
29
+
30
+ def update_comment(repo, comment_id, body)
31
+ patch_json("/repos/#{repo}/issues/comments/#{comment_id}", body: body)
32
+ end
33
+
34
+ def create_review(repo, number, body:, event: "COMMENT", comments: [], commit_id: nil)
35
+ payload = { body: body, event: event, comments: comments }
36
+ payload[:commit_id] = commit_id if commit_id
37
+ post_json("/repos/#{repo}/pulls/#{number}/reviews", payload)
38
+ end
39
+
40
+ private
41
+
42
+ def get_paginated(path)
43
+ results = []
44
+ page = 1
45
+
46
+ loop do
47
+ data, headers = request(:get, "#{path}?per_page=100&page=#{page}")
48
+ results.concat(data)
49
+ break unless headers["link"]&.include?("rel=\"next\"")
50
+
51
+ page += 1
52
+ end
53
+
54
+ results
55
+ end
56
+
57
+ def get_json(path)
58
+ data, = request(:get, path)
59
+ data
60
+ end
61
+
62
+ def post_json(path, payload)
63
+ data, = request(:post, path, payload)
64
+ data
65
+ end
66
+
67
+ def patch_json(path, payload)
68
+ data, = request(:patch, path, payload)
69
+ data
70
+ end
71
+
72
+ def request(method, path, payload = nil)
73
+ uri = URI.join(@api_url, path)
74
+ http = Net::HTTP.new(uri.host, uri.port)
75
+ http.use_ssl = uri.scheme == "https"
76
+
77
+ request_class = Net::HTTP.const_get(method.to_s.capitalize)
78
+ request = request_class.new(uri)
79
+ request["Accept"] = "application/vnd.github+json"
80
+ request["User-Agent"] = "nomos"
81
+ request["Authorization"] = "Bearer #{@token}" if @token
82
+ request["Content-Type"] = "application/json" if payload
83
+ request.body = JSON.generate(payload) if payload
84
+
85
+ response = http.request(request)
86
+ data = response.body.to_s.empty? ? {} : JSON.parse(response.body)
87
+
88
+ unless response.is_a?(Net::HTTPSuccess)
89
+ message = data.is_a?(Hash) ? data["message"] : response.message
90
+ raise Nomos::Error, "GitHub API error (#{response.code}): #{message}"
91
+ end
92
+
93
+ [data, response.each_header.to_h]
94
+ rescue JSON::ParserError => e
95
+ raise Nomos::Error, "GitHub API JSON error: #{e.message}"
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nomos
4
+ module Reporters
5
+ class Console
6
+ def report(findings)
7
+ if findings.empty?
8
+ puts "Nomos: no findings"
9
+ return
10
+ end
11
+
12
+ findings.each do |finding|
13
+ location = if finding.file
14
+ " (#{finding.file}#{finding.line ? ":#{finding.line}" : ""})"
15
+ else
16
+ ""
17
+ end
18
+
19
+ puts "[#{finding.severity}] #{finding.text}#{location}"
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end