jobcompat 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.
data/exe/jobcompat ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env ruby
2
+ require "jobcompat"
3
+ exit Jobcompat::CLI.start(ARGV)
@@ -0,0 +1,340 @@
1
+ require "prism"
2
+
3
+ module Jobcompat
4
+ Location = Data.define(:revision, :path, :line, :column, :role, :excerpt) do
5
+ def as_json
6
+ {revision: revision, path: path, line: line, column: column, role: role}
7
+ end
8
+ end
9
+ Contract = Data.define(:name, :min_arity, :max_arity, :signature_kind, :signature, :unknown_reason, :locations, :declaration_locations, :fingerprint_parts) do
10
+ def known? = unknown_reason.nil?
11
+ def accepts?(arity) = known? && arity >= min_arity && (max_arity.nil? || arity <= max_arity)
12
+ def superset_of?(other)
13
+ known? && other.known? && min_arity <= other.min_arity && (max_arity.nil? || (!other.max_arity.nil? && other.max_arity <= max_arity))
14
+ end
15
+ def display_range = max_arity.nil? ? "#{min_arity}..∞" : (min_arity == max_arity ? min_arity.to_s : "#{min_arity}..#{max_arity}")
16
+ def as_json
17
+ {status: known? ? "known" : "unknown", min_arity: min_arity, max_arity: max_arity, variadic: known? ? max_arity.nil? : nil,
18
+ signature_kind: signature_kind, signature: signature, unknown_reason: unknown_reason}
19
+ end
20
+ end
21
+ Call = Data.define(:revision, :path, :location, :receiver, :root, :namespace, :scope, :method, :arity, :unknown_reason, :fingerprint_parts)
22
+ Unknown = Data.define(:revision, :kind, :reason, :worker, :locations, :fingerprint_parts)
23
+ Fragment = Data.define(:name, :declaration, :includes, :include_tokens, :performs, :lexical_scope)
24
+
25
+ class DefinedConstantIndex
26
+ BUDGET = 67_108_864
27
+ attr_reader :selected, :outside, :ambiguous, :unverified
28
+
29
+ def initialize
30
+ @selected = Hash.new { |hash, key| hash[key] = [] }
31
+ @outside = Hash.new { |hash, key| hash[key] = [] }
32
+ @ambiguous = []
33
+ @unverified = {}
34
+ end
35
+
36
+ def add(name, location, selected:, ambiguous: false)
37
+ if ambiguous
38
+ @ambiguous << [name.split("::").last, location]
39
+ else
40
+ (selected ? @selected : @outside)[name] << location
41
+ end
42
+ end
43
+
44
+ def status(name, recognized: false)
45
+ return "recognized_worker" if recognized
46
+ return "defined_unrecognized" unless selected.fetch(name, []).empty?
47
+ return "outside_scan_scope" unless outside.fetch(name, []).empty?
48
+ return "unverified" if unverified[name] || ambiguous.any? { |leaf, _| leaf == name.split("::").last }
49
+ "absent"
50
+ end
51
+
52
+ def locations(name)
53
+ selected.fetch(name, []) + outside.fetch(name, []) + ambiguous.filter_map { |leaf, location| location if leaf == name.split("::").last } + [unverified[name]].compact
54
+ end
55
+ end
56
+
57
+ class RevisionSnapshot
58
+ attr_reader :label, :ref, :sha, :workers, :calls, :unknowns, :index, :files_scanned, :tracked_ruby
59
+
60
+ def initialize(label:, ref:, sha:, workers:, calls:, unknowns:, index:, files_scanned:, tracked_ruby:)
61
+ @label, @ref, @sha, @workers, @calls, @unknowns, @index, @files_scanned, @tracked_ruby = label, ref, sha, workers, calls, unknowns, index, files_scanned, tracked_ruby
62
+ end
63
+
64
+ def check_presence(names, repository, config)
65
+ unresolved = names.reject { |name| index.status(name, recognized: workers.key?(name)) != "absent" }
66
+ return if unresolved.empty?
67
+ consumed = 0
68
+ eligible = []
69
+ budget_boundary = nil
70
+ tracked_ruby.reject { |entry| config.scan?(entry.path) }.each do |entry|
71
+ if consumed + entry.size > DefinedConstantIndex::BUDGET
72
+ budget_boundary = entry
73
+ break
74
+ end
75
+ consumed += entry.size
76
+ eligible << entry
77
+ end
78
+ repository.each_blob(eligible) do |entry, bytes|
79
+ candidates = unresolved.select do |name|
80
+ leaf = name.split("::").last
81
+ !leaf.ascii_only? || bytes.b.include?(leaf.b)
82
+ end
83
+ next if candidates.empty?
84
+ begin
85
+ Analyzer.new(label, entry.path, bytes, index: index, presence_only: true).analyze
86
+ rescue Error
87
+ candidates.each { |name| index.unverified[name] = Location.new(label, entry.path, 1, 1, "worker_declaration", nil) }
88
+ end
89
+ unresolved.reject! { |name| !index.outside.fetch(name, []).empty? }
90
+ :stop if unresolved.empty?
91
+ end
92
+ if budget_boundary
93
+ unresolved.each do |name|
94
+ index.unverified[name] ||= Location.new(label, budget_boundary.path, 1, 1, "worker_declaration", nil)
95
+ end
96
+ end
97
+ end
98
+ end
99
+
100
+ class Analyzer
101
+ SIMPLE_CONSTANT_BINDINGS = [Prism::ConstantWriteNode, Prism::ConstantOrWriteNode,
102
+ Prism::ConstantAndWriteNode, Prism::ConstantOperatorWriteNode,
103
+ Prism::ConstantTargetNode].freeze
104
+ PATH_CONSTANT_BINDINGS = [Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode,
105
+ Prism::ConstantPathAndWriteNode, Prism::ConstantPathOperatorWriteNode,
106
+ Prism::ConstantPathTargetNode].freeze
107
+ attr_reader :fragments, :calls, :unknowns
108
+
109
+ def initialize(revision, path, source, index:, presence_only: false)
110
+ @revision, @path, @source, @index, @presence_only = revision, path, source, index, presence_only
111
+ @fragments, @calls, @unknowns = [], [], []
112
+ end
113
+
114
+ def analyze
115
+ result = Prism.parse(@source, filepath: @path)
116
+ @source_encoding = result.source.encoding
117
+ unless result.errors.empty?
118
+ diagnostics = result.errors.map do |error|
119
+ {category: "parse_error", message: "#{@path}:#{error.location.start_line}:#{error.message}",
120
+ location: diagnostic_location(error.location.start_line, error.location.start_column + 1)}
121
+ end
122
+ raise Error.new(diagnostics.first[:message], category: "parse_error", location: diagnostics.first[:location], diagnostics: diagnostics)
123
+ end
124
+ walk(result.value, [], [], nil)
125
+ self
126
+ end
127
+
128
+ def self.constant(node)
129
+ case node
130
+ when Prism::ConstantReadNode then [[node.name.to_s.encode(Encoding::UTF_8)], false]
131
+ when Prism::ConstantPathNode, Prism::ConstantPathTargetNode
132
+ if node.parent.nil?
133
+ [[node.name.to_s.encode(Encoding::UTF_8)], true]
134
+ else
135
+ parent = constant(node.parent)
136
+ parent && [parent[0] + [node.name.to_s.encode(Encoding::UTF_8)], parent[1]]
137
+ end
138
+ end
139
+ end
140
+
141
+ def self.normalize(source)
142
+ Prism.lex(source).value.filter_map do |pair|
143
+ token = pair.first
144
+ [token.type, token.value] unless %i[COMMENT EOF IGNORED_NEWLINE NEWLINE].include?(token.type)
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def walk(node, namespace, scope, statement)
151
+ return unless node.is_a?(Prism::Node)
152
+ case node
153
+ when Prism::StatementsNode
154
+ node.body.each { |child| walk(child, namespace, scope, child) }
155
+ when Prism::ClassNode, Prism::ModuleNode
156
+ constant = self.class.constant(node.constant_path)
157
+ name, valid = declaration_name(constant, namespace)
158
+ location = loc(node, "worker_declaration")
159
+ @index.add(name, location, selected: !@presence_only, ambiguous: !valid) if name
160
+ if !name && node.constant_path.is_a?(Prism::ConstantPathNode)
161
+ @index.add(node.constant_path.name.to_s.encode(Encoding::UTF_8), location, selected: !@presence_only, ambiguous: true)
162
+ end
163
+ new_scope = scope + [name || "<unsupported_class>"]
164
+ if node.is_a?(Prism::ClassNode) && valid && !@presence_only
165
+ body = node.body.is_a?(Prism::StatementsNode) ? node.body.body : []
166
+ include_nodes = body.select { |child| sidekiq_include?(child) }
167
+ includes = include_nodes.map { |child| loc(child, "worker_declaration") }
168
+ performs = body.select { |child| child.is_a?(Prism::DefNode) && child.receiver.nil? && child.name == :perform }
169
+ @fragments << Fragment.new(name, location, includes, include_nodes.map { |child| self.class.normalize(child.location.slice) },
170
+ performs.map { |perform| [perform, loc(perform, "consumer"), signature(perform)] }, new_scope)
171
+ elsif node.is_a?(Prism::ClassNode) && !valid && !@presence_only && node.body.is_a?(Prism::StatementsNode) && node.body.body.any? { |child| sidekiq_include?(child) }
172
+ include_nodes = node.body.body.select { |child| sidekiq_include?(child) }
173
+ include_tokens = include_nodes.map { |child| self.class.normalize(child.location.slice) }.sort_by(&:inspect)
174
+ @unknowns << Unknown.new(@revision, "worker_identity", "unsupported_constant_path", nil,
175
+ [location] + include_nodes.map { |child| loc(child, "worker_declaration") },
176
+ [@path, new_scope, self.class.normalize(node.constant_path.location.slice), include_tokens])
177
+ end
178
+ child_namespace = valid && namespace ? namespace + [name] : nil
179
+ walk(node.superclass, namespace, scope, statement) if node.is_a?(Prism::ClassNode) && node.superclass
180
+ walk(node.body, child_namespace, new_scope, nil) if node.body
181
+ when Prism::DefNode
182
+ method_scope = node.receiver ? [self.class.normalize(node.receiver.location.slice), node.name.to_s] : node.name.to_s
183
+ child_scope = scope + [method_scope]
184
+ walk(node.receiver, namespace, scope, statement) if node.receiver
185
+ walk(node.parameters, namespace, child_scope, node.parameters) if node.parameters
186
+ walk(node.body, namespace, child_scope, nil) if node.body
187
+ when Prism::SingletonClassNode
188
+ walk(node.expression, namespace, scope, statement)
189
+ singleton_scope = scope + [["singleton_class", self.class.normalize(node.expression.location.slice)]]
190
+ walk(node.body, namespace, singleton_scope, nil) if node.body
191
+ when Prism::CallNode
192
+ extract_call(node, namespace, scope, statement) unless @presence_only
193
+ node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement) }
194
+ else
195
+ constant_write(node, namespace) if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) } || PATH_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
196
+ node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement) }
197
+ end
198
+ end
199
+
200
+ def declaration_name(constant, namespace)
201
+ return [nil, false] unless constant
202
+ segments, rooted = constant
203
+ return [segments.join("::"), false] if namespace.nil? && !rooted
204
+ return [segments.join("::"), false] if segments.length > 1 && !rooted && !namespace.empty?
205
+ [((rooted || segments.length > 1) ? segments : (namespace.empty? ? [] : namespace.last.split("::")) + segments).join("::"), true]
206
+ end
207
+
208
+ def constant_write(node, namespace)
209
+ if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
210
+ prefix = namespace.nil? || namespace.empty? ? [] : namespace.last.split("::")
211
+ @index.add((prefix + [node.name.to_s.encode(Encoding::UTF_8)]).join("::"), loc(node, "worker_declaration"),
212
+ selected: !@presence_only, ambiguous: namespace.nil?)
213
+ else
214
+ target = node.is_a?(Prism::ConstantPathTargetNode) ? node : node.target
215
+ constant = self.class.constant(target)
216
+ name, valid = declaration_name(constant, namespace)
217
+ @index.add(name, loc(node, "worker_declaration"), selected: !@presence_only, ambiguous: !valid) if name
218
+ if !name && (target.is_a?(Prism::ConstantPathNode) || target.is_a?(Prism::ConstantPathTargetNode))
219
+ @index.add(target.name.to_s.encode(Encoding::UTF_8), loc(node, "worker_declaration"), selected: !@presence_only, ambiguous: true)
220
+ end
221
+ end
222
+ end
223
+
224
+ def sidekiq_include?(node)
225
+ return false unless node.is_a?(Prism::CallNode) && node.receiver.nil? && node.name == :include
226
+ (node.arguments&.arguments || []).any? do |arg|
227
+ constant = self.class.constant(arg)
228
+ constant && %w[Sidekiq::Job Sidekiq::Worker].include?(constant[0].join("::"))
229
+ end
230
+ end
231
+
232
+ def signature(node)
233
+ finish = node.rparen_loc ? node.rparen_loc.end_offset : (node.parameters ? node.parameters.location.end_offset : node.name_loc.end_offset)
234
+ header = @source.byteslice(node.location.start_offset...finish).to_s.force_encoding(@source_encoding).strip
235
+ header.gsub(/\Adef\s+/, "").encode(Encoding::UTF_8)
236
+ end
237
+
238
+ def extract_call(node, namespace, scope, statement)
239
+ method = node.name.to_s
240
+ return unless %w[perform_async perform_in perform_at].include?(method)
241
+ return if node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :set && method != "perform_async"
242
+ receiver = node.receiver
243
+ set_call = receiver if receiver.is_a?(Prism::CallNode) && receiver.name == :set && method == "perform_async"
244
+ receiver = set_call.receiver if set_call
245
+ constant = self.class.constant(receiver)
246
+ safe = node.call_operator_loc&.slice == "&." || set_call&.call_operator_loc&.slice == "&."
247
+ arguments = node.arguments&.arguments || []
248
+ reason = if arguments.any? { |arg| arg.is_a?(Prism::SplatNode) }
249
+ "splat_arguments"
250
+ elsif arguments.any? { |arg| arg.is_a?(Prism::ForwardingArgumentsNode) }
251
+ "forwarded_arguments"
252
+ elsif method != "perform_async" && arguments.empty?
253
+ "missing_schedule_argument"
254
+ elsif constant.nil? && !receiver.is_a?(Prism::ConstantPathNode)
255
+ "dynamic_receiver"
256
+ elsif constant.nil?
257
+ "unsupported_constant_path"
258
+ elsif safe
259
+ "safe_navigation_receiver"
260
+ end
261
+ arity = reason && %w[splat_arguments forwarded_arguments missing_schedule_argument].include?(reason) ? nil : arguments.length - (method == "perform_async" ? 0 : 1)
262
+ expression = self.class.normalize(node.location.slice)
263
+ enclosing = self.class.normalize((statement || node).location.slice)
264
+ @calls << Call.new(@revision, @path, loc(node, reason ? "unknown_call" : "producer"), constant&.first&.join("::"), constant&.last,
265
+ namespace, scope, method, arity, reason, [@path, scope, expression, enclosing])
266
+ end
267
+
268
+ def loc(node, role)
269
+ Location.new(@revision, @path, node.location.start_line, node.location.start_column + 1, role, node.location.slice.to_s.lines.first&.strip&.encode(Encoding::UTF_8))
270
+ end
271
+
272
+ def diagnostic_location(line, column)
273
+ {revision: @revision, path: @path, line: line, column: column}
274
+ end
275
+ end
276
+
277
+ class SnapshotBuilder
278
+ def initialize(repository, config)
279
+ @repository, @config = repository, config
280
+ end
281
+
282
+ def build(label, ref, sha)
283
+ entries = @repository.entries(sha)
284
+ selected = entries.select { |entry| @config.scan?(entry.path) }
285
+ tracked = entries.select { |entry| entry.path.end_with?(".rb") }
286
+ index = DefinedConstantIndex.new
287
+ fragments, calls, unknowns, errors = [], [], [], []
288
+ @repository.each_blob(selected) do |entry, bytes|
289
+ path = entry.path
290
+ begin
291
+ result = Analyzer.new(label, path, bytes, index: index).analyze
292
+ fragments.concat(result.fragments)
293
+ calls.concat(result.calls)
294
+ unknowns.concat(result.unknowns)
295
+ rescue Error => error
296
+ errors << error
297
+ end
298
+ end
299
+ unless errors.empty?
300
+ diagnostics = errors.flat_map do |error|
301
+ error.diagnostics || [{category: error.category, message: error.message, location: error.location}]
302
+ end.sort_by { |item| [item[:location]&.fetch(:revision, ""), item[:location]&.fetch(:path, ""), item[:location]&.fetch(:line, 0), item[:location]&.fetch(:column, 0), item[:message]] }
303
+ raise Error.new(diagnostics.first[:message], category: "parse_error", location: diagnostics.first[:location], diagnostics: diagnostics)
304
+ end
305
+ workers = {}
306
+ fragments.group_by(&:name).sort.each do |name, group|
307
+ next if group.flat_map(&:includes).empty?
308
+ relevant = group.select { |fragment| !fragment.includes.empty? || !fragment.performs.empty? }
309
+ declarations = relevant.map(&:declaration).sort_by { |loc| [loc.path, loc.line, loc.column] }
310
+ performs = group.flat_map(&:performs).sort_by { |entry| [entry[1].path, entry[1].line, entry[1].column] }
311
+ includes = group.flat_map(&:includes)
312
+ locations = (declarations + includes + performs.map { |entry| entry[1] }).uniq.sort_by { |loc| [loc.path, loc.line, loc.column, loc.role] }
313
+ fingerprint_parts = [relevant.map { |fragment| [fragment.declaration.path, fragment.lexical_scope] }.sort_by(&:inspect),
314
+ relevant.flat_map(&:include_tokens).sort_by(&:inspect),
315
+ performs.map { |entry| Analyzer.normalize(entry.last) }.sort_by(&:inspect)]
316
+ if performs.length != 1
317
+ reason = performs.empty? ? "missing_perform" : "multiple_perform_definitions"
318
+ signatures = performs.map(&:last).sort.join(" | ")
319
+ workers[name] = Contract.new(name, nil, nil, nil, signatures.empty? ? nil : signatures, reason, locations, declarations, fingerprint_parts)
320
+ next
321
+ end
322
+ node, _, signature = performs.first
323
+ params = node.parameters
324
+ reason = params && (!params.keywords.empty? || (params.keyword_rest && !params.keyword_rest.is_a?(Prism::ForwardingParameterNode))) ? "keyword_parameters" : nil
325
+ reason ||= "unsupported_parameters" if params && !(params.is_a?(Prism::ParametersNode))
326
+ if reason
327
+ workers[name] = Contract.new(name, nil, nil, nil, signature, reason, locations, declarations, fingerprint_parts)
328
+ else
329
+ required = params ? params.requireds.length + params.posts.length : 0
330
+ optional = params ? params.optionals.length : 0
331
+ forwarding = params && params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
332
+ rest = params && (params.rest || forwarding)
333
+ workers[name] = Contract.new(name, required, rest ? nil : required + optional, forwarding ? "forwarding" : "positional", signature, nil, locations, declarations, fingerprint_parts)
334
+ end
335
+ end
336
+ RevisionSnapshot.new(label: label, ref: ref, sha: sha, workers: workers, calls: calls, unknowns: unknowns, index: index,
337
+ files_scanned: selected.length, tracked_ruby: tracked)
338
+ end
339
+ end
340
+ end
@@ -0,0 +1,107 @@
1
+ require "optparse"
2
+
3
+ module Jobcompat
4
+ class CLI
5
+ def self.start(args, stdout: $stdout, stderr: $stderr, directory: Dir.pwd)
6
+ new(args, stdout, stderr, directory).run
7
+ end
8
+
9
+ def initialize(args, stdout, stderr, directory)
10
+ @args, @stdout, @stderr, @directory = args.dup, stdout, stderr, directory
11
+ @base, @head, @format, @config_path = nil, "HEAD", "text", nil
12
+ @base_sha = @head_sha = @loaded_config = nil
13
+ end
14
+
15
+ def run
16
+ return help if @args.empty? || %w[-h --help].include?(@args.first)
17
+ return version if @args == ["--version"]
18
+ raise Error.new("Unknown command: #{@args.first}", category: "config_error") unless @args.shift == "check"
19
+ parser = OptionParser.new do |options|
20
+ options.banner = "Usage: jobcompat check --base REF [options]"
21
+ options.on("--base REF", "Base commit-ish (required)") { |value| @base = value }
22
+ options.on("--head REF", "Head commit-ish (default HEAD)") { |value| @head = value }
23
+ options.on("--format FORMAT", "text or json") { |value| @format = value }
24
+ options.on("--config PATH", "Config YAML path") { |value| @config_path = value }
25
+ options.on("-h", "--help", "Show help") { @stdout.puts(options); return 0 }
26
+ end
27
+ parser.parse!(@args)
28
+ raise Error.new("Unexpected arguments: #{@args.join(' ')}", category: "config_error") unless @args.empty?
29
+ raise Error.new("--base is required", category: "config_error") unless @base && !@base.empty?
30
+ raise Error.new("--format must be text or json", category: "config_error") unless %w[text json].include?(@format)
31
+ repository = GitRepository.new(@directory)
32
+ config = Config.load(root: repository.root, explicit: @config_path, invocation_dir: @directory)
33
+ @loaded_config = config.display_path
34
+ @base_sha = repository.resolve(@base, "base")
35
+ @head_sha = repository.resolve(@head, "head")
36
+ builder = SnapshotBuilder.new(repository, config)
37
+ snapshots = {}
38
+ parse_diagnostics = []
39
+ [["base", @base, @base_sha], ["head", @head, @head_sha]].each do |label, ref, sha|
40
+ begin
41
+ snapshots[label] = builder.build(label, ref, sha)
42
+ rescue Error => error
43
+ raise unless error.category == "parse_error"
44
+ parse_diagnostics.concat(error.diagnostics || [{category: error.category, message: error.message, location: error.location}])
45
+ end
46
+ end
47
+ unless parse_diagnostics.empty?
48
+ parse_diagnostics.sort_by! do |item|
49
+ location = item[:location] || {}
50
+ [location.fetch(:revision, ""), location.fetch(:path, ""), location.fetch(:line, 0), location.fetch(:column, 0), item[:message]]
51
+ end
52
+ raise Error.new(parse_diagnostics.first[:message], category: "parse_error", location: parse_diagnostics.first[:location], diagnostics: parse_diagnostics)
53
+ end
54
+ base = snapshots.fetch("base")
55
+ head = snapshots.fetch("head")
56
+ engine = Engine.new(base, head)
57
+ requests = engine.presence_requests
58
+ head.check_presence(requests["head"], repository, config)
59
+ base.check_presence(requests["base"], repository, config)
60
+ result = engine.evaluate
61
+ findings, suppressions = config.suppressions(result[:findings])
62
+ summary = {
63
+ errors: findings.count { |item| item[:severity] == "error" }, warnings: findings.count { |item| item[:severity] == "warning" },
64
+ suppressed: suppressions.sum { |item| item[:finding_count] },
65
+ files_scanned: {base: base.files_scanned, head: head.files_scanned},
66
+ workers: {base: base.workers.length, head: head.workers.length},
67
+ enqueue_calls: {base: base.calls.length, head: head.calls.length,
68
+ unknown: engine.calls.values.flatten.count { |call| call[:reason] }}
69
+ }
70
+ @stdout.write(Formatter.public_send(@format, envelope("completed", findings, suppressions, result[:workers], [], summary)))
71
+ summary[:errors].positive? ? 1 : 0
72
+ rescue OptionParser::ParseError => error
73
+ failure(Error.new(error.message, category: "config_error"))
74
+ rescue Error => error
75
+ failure(error)
76
+ rescue StandardError => error
77
+ @stderr.puts(Formatter.safe_line("#{error.class}: #{error.message}"))
78
+ failure(Error.new("Internal analysis error.", category: "internal_error"))
79
+ end
80
+
81
+ private
82
+
83
+ def help
84
+ @stdout.puts("jobcompat #{VERSION}\nUsage: jobcompat check --base REF [options]\n jobcompat --help\n jobcompat --version")
85
+ 0
86
+ end
87
+
88
+ def version
89
+ @stdout.puts("jobcompat #{VERSION}")
90
+ 0
91
+ end
92
+
93
+ def failure(error)
94
+ diagnostics = error.diagnostics || [{category: error.category, message: error.message, location: error.location}]
95
+ output = Formatter.public_send(@format == "json" ? :json : :text, envelope("failed", [], [], [], diagnostics, nil))
96
+ (@format == "json" ? @stdout : @stderr).write(output)
97
+ 2
98
+ end
99
+
100
+ def envelope(status, findings, suppressions, workers, diagnostics, summary)
101
+ {schema_version: 1, tool: {name: "jobcompat", version: VERSION}, status: status,
102
+ comparison: {deployment_model: "rolling", base: {ref: @base, sha: @base_sha}, head: {ref: @head, sha: @head_sha}},
103
+ configuration: {path: @loaded_config}, findings: findings, suppressions: suppressions, workers: workers,
104
+ diagnostics: diagnostics, summary: summary}
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,89 @@
1
+ require "psych"
2
+
3
+ module Jobcompat
4
+ class Config
5
+ DEFAULT_INCLUDE = ["**/*.rb"].freeze
6
+ DEFAULT_EXCLUDE = %w[vendor/** tmp/** log/** coverage/** .bundle/** test/** spec/** features/** examples/**].freeze
7
+ RULES = (1..7).map { |number| format("JC%03d", number) }.freeze
8
+ GLOB_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
9
+
10
+ attr_reader :include_patterns, :exclude_patterns, :ignore, :display_path
11
+
12
+ def self.load(root:, explicit: nil, invocation_dir: Dir.pwd)
13
+ path = explicit ? File.expand_path(explicit, invocation_dir) : File.join(root, ".jobcompat.yml")
14
+ unless File.file?(path)
15
+ raise Error.new("Config file is not a readable file: #{path}", category: "config_error") if explicit || File.exist?(path) || File.symlink?(path)
16
+ return new(DEFAULT_INCLUDE, DEFAULT_EXCLUDE, [], nil)
17
+ end
18
+
19
+ document = Psych.safe_load(File.read(path), permitted_classes: [], permitted_symbols: [], aliases: false)
20
+ validate(document, path == File.join(root, ".jobcompat.yml") ? ".jobcompat.yml" : path)
21
+ rescue Psych::Exception, EncodingError, SystemCallError => e
22
+ raise Error.new("Invalid config: #{e.message}", category: "config_error")
23
+ end
24
+
25
+ def self.validate(data, display_path)
26
+ fields!(data, %w[version scan ignore], "config")
27
+ raise Error.new("version must be integer 1", category: "config_error") unless data["version"] == 1 && data["version"].instance_of?(Integer)
28
+ scan = data.fetch("scan", {})
29
+ fields!(scan, %w[include exclude], "scan")
30
+ includes = patterns!(scan.fetch("include", DEFAULT_INCLUDE), "scan.include", empty: false)
31
+ excludes = patterns!(scan.fetch("exclude", DEFAULT_EXCLUDE), "scan.exclude", empty: true)
32
+ ignores = data.fetch("ignore", [])
33
+ raise Error.new("ignore must be an array", category: "config_error") unless ignores.is_a?(Array)
34
+ seen = {}
35
+ ignores.each_with_index do |item, index|
36
+ fields!(item, %w[rule worker reason], "ignore[#{index}]")
37
+ raise Error.new("ignore[#{index}] must have rule, worker, reason", category: "config_error") unless item.keys.sort == %w[reason rule worker]
38
+ raise Error.new("ignore[#{index}].rule is invalid", category: "config_error") unless RULES.include?(item["rule"])
39
+ worker = item["worker"]
40
+ valid_worker = worker.is_a?(String) && !worker.empty? && !worker.start_with?("::") && worker.split("::", -1).all? do |segment|
41
+ first = segment.each_char.first
42
+ first && (first.match?(/[A-Z]/) || (first.ord > 127 && first.match?(/\p{L}/))) && segment.match?(/\A[\p{Alnum}_]+\z/)
43
+ end
44
+ raise Error.new("ignore[#{index}].worker is invalid", category: "config_error") unless valid_worker
45
+ raise Error.new("ignore[#{index}].reason must be non-blank", category: "config_error") unless item["reason"].is_a?(String) && !item["reason"].strip.empty?
46
+ key = [item["rule"], item["worker"]]
47
+ raise Error.new("duplicate ignore for #{key.join(' ')}", category: "config_error") if seen[key]
48
+ seen[key] = true
49
+ end
50
+ new(includes, excludes, ignores, display_path)
51
+ end
52
+
53
+ def self.fields!(value, allowed, label)
54
+ raise Error.new("#{label} must be a mapping", category: "config_error") unless value.is_a?(Hash)
55
+ unknown = value.keys - allowed
56
+ raise Error.new("#{label} has unknown keys: #{unknown.join(', ')}", category: "config_error") unless unknown.empty?
57
+ end
58
+
59
+ def self.patterns!(value, label, empty:)
60
+ raise Error.new("#{label} must be #{empty ? 'an array' : 'a non-empty array'}", category: "config_error") unless value.is_a?(Array) && (empty || !value.empty?)
61
+ value.each_with_index do |glob, index|
62
+ valid = glob.is_a?(String) && !glob.empty? && !glob.start_with?("/") && !glob.include?("\0") && !glob.split("/").include?("..")
63
+ raise Error.new("#{label}[#{index}] is not a repository-relative glob", category: "config_error") unless valid
64
+ end
65
+ value
66
+ end
67
+
68
+ def initialize(includes, excludes, ignore, display_path)
69
+ @include_patterns, @exclude_patterns, @ignore, @display_path = includes.freeze, excludes.freeze, ignore.freeze, display_path
70
+ end
71
+
72
+ def scan?(path)
73
+ include_patterns.any? { |glob| File.fnmatch?(glob, path, GLOB_FLAGS) } &&
74
+ exclude_patterns.none? { |glob| File.fnmatch?(glob, path, GLOB_FLAGS) }
75
+ end
76
+
77
+ def suppressions(findings)
78
+ matched = []
79
+ remaining = findings.reject do |finding|
80
+ entry = ignore.find { |item| item["rule"] == finding[:rule_id] && item["worker"] == finding[:worker] }
81
+ next false unless entry
82
+ record = matched.find { |item| item[:rule_id] == entry["rule"] && item[:worker] == entry["worker"] }
83
+ record ? record[:finding_count] += 1 : matched << {rule_id: entry["rule"], worker: entry["worker"], reason: entry["reason"], finding_count: 1}
84
+ true
85
+ end
86
+ [remaining, matched.sort_by { |item| [item[:rule_id], item[:worker]] }]
87
+ end
88
+ end
89
+ end