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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +161 -0
- data/SECURITY.md +5 -0
- data/docs/architecture.md +587 -0
- data/docs/competitive-analysis.md +268 -0
- data/docs/implementation-plan.md +806 -0
- data/docs/release-checklist-v0.1.0.md +49 -0
- data/docs/release-notes-v0.1.0.md +24 -0
- data/docs/spec-v0.1.md +1157 -0
- data/exe/jobcompat +3 -0
- data/lib/jobcompat/analysis.rb +340 -0
- data/lib/jobcompat/cli.rb +107 -0
- data/lib/jobcompat/config.rb +89 -0
- data/lib/jobcompat/engine.rb +247 -0
- data/lib/jobcompat/errors.rb +12 -0
- data/lib/jobcompat/formatter.rb +64 -0
- data/lib/jobcompat/git_repository.rb +78 -0
- data/lib/jobcompat/version.rb +3 -0
- data/lib/jobcompat.rb +8 -0
- metadata +84 -0
data/exe/jobcompat
ADDED
|
@@ -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
|