jobcompat 0.2.1 → 0.3.1
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 +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +34 -4
- data/SECURITY.md +5 -1
- data/docs/architecture.md +45 -5
- data/docs/implementation-plan-v0.3.1.md +54 -0
- data/docs/implementation-plan-v0.3.md +32 -0
- data/docs/release-checklist-v0.3.0.md +109 -0
- data/docs/release-notes-v0.3.0.md +115 -0
- data/docs/spec-v0.3.1.md +202 -0
- data/docs/spec-v0.3.md +238 -0
- data/lib/jobcompat/aliases.rb +86 -0
- data/lib/jobcompat/analysis.rb +76 -22
- data/lib/jobcompat/cli.rb +12 -3
- data/lib/jobcompat/engine.rb +170 -63
- data/lib/jobcompat/explanation.rb +144 -0
- data/lib/jobcompat/formatter.rb +11 -1
- data/lib/jobcompat/version.rb +1 -1
- data/lib/jobcompat.rb +1 -0
- metadata +9 -1
data/lib/jobcompat/analysis.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "prism"
|
|
2
2
|
require_relative "canonical_worker_name"
|
|
3
|
+
require_relative "aliases"
|
|
3
4
|
|
|
4
5
|
module Jobcompat
|
|
5
6
|
Location = Data.define(:revision, :path, :line, :column, :role, :excerpt) do
|
|
@@ -56,14 +57,32 @@ module Jobcompat
|
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
class RevisionSnapshot
|
|
59
|
-
attr_reader :label, :ref, :sha, :workers, :calls, :unknowns, :index, :files_scanned, :tracked_ruby
|
|
60
|
+
attr_reader :label, :ref, :sha, :workers, :aliases, :bindings, :calls, :unknowns, :index, :files_scanned, :tracked_ruby
|
|
60
61
|
|
|
61
|
-
def initialize(label:, ref:, sha:, workers:, calls:, unknowns:, index:, files_scanned:, tracked_ruby:)
|
|
62
|
+
def initialize(label:, ref:, sha:, workers:, calls:, unknowns:, index:, files_scanned:, tracked_ruby:, aliases: {}, bindings: [])
|
|
62
63
|
@label, @ref, @sha, @workers, @calls, @unknowns, @index, @files_scanned, @tracked_ruby = label, ref, sha, workers, calls, unknowns, index, files_scanned, tracked_ruby
|
|
64
|
+
@aliases = aliases
|
|
65
|
+
@bindings = bindings.group_by(&:name).freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def consumer(name)
|
|
69
|
+
binding = aliases[name]
|
|
70
|
+
return workers[name] unless binding
|
|
71
|
+
return unless binding.resolved?
|
|
72
|
+
terminal = workers.fetch(binding.target)
|
|
73
|
+
terminal.with(name: name, locations: (binding.locations + terminal.locations).uniq,
|
|
74
|
+
declaration_locations: (binding.locations + terminal.declaration_locations).uniq,
|
|
75
|
+
fingerprint_parts: [binding.fingerprint_parts, terminal.fingerprint_parts])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def presence(name)
|
|
79
|
+
binding = aliases[name]
|
|
80
|
+
return binding.resolved? ? "resolved_alias" : "defined_unrecognized" if binding
|
|
81
|
+
index.status(name, recognized: workers.key?(name))
|
|
63
82
|
end
|
|
64
83
|
|
|
65
84
|
def check_presence(names, repository, config)
|
|
66
|
-
unresolved = names.
|
|
85
|
+
unresolved = names.select { |name| presence(name) == "absent" }
|
|
67
86
|
return if unresolved.empty?
|
|
68
87
|
consumed = 0
|
|
69
88
|
eligible = []
|
|
@@ -105,11 +124,12 @@ module Jobcompat
|
|
|
105
124
|
PATH_CONSTANT_BINDINGS = [Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode,
|
|
106
125
|
Prism::ConstantPathAndWriteNode, Prism::ConstantPathOperatorWriteNode,
|
|
107
126
|
Prism::ConstantPathTargetNode].freeze
|
|
108
|
-
attr_reader :fragments, :calls, :unknowns
|
|
127
|
+
attr_reader :fragments, :calls, :unknowns, :bindings, :alias_candidates, :ambiguous_bindings
|
|
109
128
|
|
|
110
129
|
def initialize(revision, path, source, index:, presence_only: false)
|
|
111
130
|
@revision, @path, @source, @index, @presence_only = revision, path, source, index, presence_only
|
|
112
131
|
@fragments, @calls, @unknowns = [], [], []
|
|
132
|
+
@bindings, @alias_candidates, @ambiguous_bindings = [], [], []
|
|
113
133
|
end
|
|
114
134
|
|
|
115
135
|
def analyze
|
|
@@ -122,7 +142,7 @@ module Jobcompat
|
|
|
122
142
|
end
|
|
123
143
|
raise Error.new(diagnostics.first[:message], category: "parse_error", location: diagnostics.first[:location], diagnostics: diagnostics)
|
|
124
144
|
end
|
|
125
|
-
walk(result.value, [], [], nil)
|
|
145
|
+
walk(result.value, [], [], nil, true)
|
|
126
146
|
self
|
|
127
147
|
end
|
|
128
148
|
|
|
@@ -156,19 +176,30 @@ module Jobcompat
|
|
|
156
176
|
|
|
157
177
|
private
|
|
158
178
|
|
|
159
|
-
def walk(node, namespace, scope, statement)
|
|
179
|
+
def walk(node, namespace, scope, statement, alias_context = false, alias_statement = nil)
|
|
160
180
|
return unless node.is_a?(Prism::Node)
|
|
161
181
|
case node
|
|
182
|
+
when Prism::ProgramNode
|
|
183
|
+
walk(node.statements, namespace, scope, nil, alias_context)
|
|
162
184
|
when Prism::StatementsNode
|
|
163
|
-
node.body.each { |child| walk(child, namespace, scope, child) }
|
|
185
|
+
node.body.each { |child| walk(child, namespace, scope, child, alias_context, alias_context ? child : (alias_statement || child)) }
|
|
164
186
|
when Prism::ClassNode, Prism::ModuleNode
|
|
165
187
|
constant = self.class.constant(node.constant_path)
|
|
166
188
|
name, valid = declaration_name(constant, namespace)
|
|
167
189
|
location = loc(node, "worker_declaration")
|
|
168
190
|
@index.add(name, location, selected: !@presence_only, ambiguous: !valid) if name
|
|
191
|
+
if name && !@presence_only
|
|
192
|
+
@bindings << ConstantBinding.new(name, "declaration", location,
|
|
193
|
+
[scope, self.class.normalize(node.constant_path.location.slice), node.class.name])
|
|
194
|
+
end
|
|
169
195
|
if !name && node.constant_path.is_a?(Prism::ConstantPathNode)
|
|
170
196
|
@index.add(node.constant_path.name.to_s.encode(Encoding::UTF_8), location, selected: !@presence_only, ambiguous: true)
|
|
171
197
|
end
|
|
198
|
+
unless valid || @presence_only
|
|
199
|
+
leaf = name&.split("::")&.last || node.constant_path.name.to_s.encode(Encoding::UTF_8)
|
|
200
|
+
@ambiguous_bindings << ConstantBinding.new(leaf, "declaration", location,
|
|
201
|
+
[scope, self.class.normalize(node.constant_path.location.slice), node.class.name])
|
|
202
|
+
end
|
|
172
203
|
new_scope = scope + [name || "<unsupported_class>"]
|
|
173
204
|
if node.is_a?(Prism::ClassNode) && valid && !@presence_only
|
|
174
205
|
body = node.body.is_a?(Prism::StatementsNode) ? node.body.body : []
|
|
@@ -185,24 +216,25 @@ module Jobcompat
|
|
|
185
216
|
[@path, new_scope, self.class.normalize(node.constant_path.location.slice), include_tokens])
|
|
186
217
|
end
|
|
187
218
|
child_namespace = valid && namespace ? namespace + [name] : nil
|
|
188
|
-
walk(node.superclass, namespace, scope, statement) if node.is_a?(Prism::ClassNode) && node.superclass
|
|
189
|
-
|
|
219
|
+
walk(node.superclass, namespace, scope, statement, false, alias_statement) if node.is_a?(Prism::ClassNode) && node.superclass
|
|
220
|
+
body_context = alias_context && node.equal?(statement) && valid
|
|
221
|
+
walk(node.body, child_namespace, new_scope, nil, body_context, body_context ? nil : alias_statement) if node.body
|
|
190
222
|
when Prism::DefNode
|
|
191
223
|
method_scope = node.receiver ? [self.class.normalize(node.receiver.location.slice), node.name.to_s] : node.name.to_s
|
|
192
224
|
child_scope = scope + [method_scope]
|
|
193
|
-
walk(node.receiver, namespace, scope, statement) if node.receiver
|
|
194
|
-
walk(node.parameters, namespace, child_scope, node.parameters) if node.parameters
|
|
195
|
-
walk(node.body, namespace, child_scope, nil) if node.body
|
|
225
|
+
walk(node.receiver, namespace, scope, statement, false, alias_statement) if node.receiver
|
|
226
|
+
walk(node.parameters, namespace, child_scope, node.parameters, false, alias_statement) if node.parameters
|
|
227
|
+
walk(node.body, namespace, child_scope, nil, false, alias_statement) if node.body
|
|
196
228
|
when Prism::SingletonClassNode
|
|
197
|
-
walk(node.expression, namespace, scope, statement)
|
|
229
|
+
walk(node.expression, namespace, scope, statement, false, alias_statement)
|
|
198
230
|
singleton_scope = scope + [["singleton_class", self.class.normalize(node.expression.location.slice)]]
|
|
199
|
-
walk(node.body, namespace, singleton_scope, nil) if node.body
|
|
231
|
+
walk(node.body, namespace, singleton_scope, nil, false, alias_statement) if node.body
|
|
200
232
|
when Prism::CallNode
|
|
201
233
|
extract_call(node, namespace, scope, statement) unless @presence_only
|
|
202
|
-
node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement) }
|
|
234
|
+
node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement, false, alias_statement || statement) }
|
|
203
235
|
else
|
|
204
|
-
constant_write(node, namespace) if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) } || PATH_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
|
|
205
|
-
node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement) }
|
|
236
|
+
constant_write(node, namespace, scope, alias_statement || statement, alias_context && node.equal?(statement)) if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) } || PATH_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
|
|
237
|
+
node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement, false, alias_statement || statement) }
|
|
206
238
|
end
|
|
207
239
|
end
|
|
208
240
|
|
|
@@ -214,10 +246,12 @@ module Jobcompat
|
|
|
214
246
|
[((rooted || segments.length > 1) ? segments : (namespace.empty? ? [] : namespace.last.split("::")) + segments).join("::"), true]
|
|
215
247
|
end
|
|
216
248
|
|
|
217
|
-
def constant_write(node, namespace)
|
|
249
|
+
def constant_write(node, namespace, scope, statement, alias_context)
|
|
218
250
|
if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
|
|
219
251
|
prefix = namespace.nil? || namespace.empty? ? [] : namespace.last.split("::")
|
|
220
|
-
|
|
252
|
+
name = (prefix + [node.name.to_s.encode(Encoding::UTF_8)]).join("::")
|
|
253
|
+
valid = !namespace.nil?
|
|
254
|
+
@index.add(name, loc(node, "worker_declaration"),
|
|
221
255
|
selected: !@presence_only, ambiguous: namespace.nil?)
|
|
222
256
|
else
|
|
223
257
|
target = node.is_a?(Prism::ConstantPathTargetNode) ? node : node.target
|
|
@@ -228,6 +262,22 @@ module Jobcompat
|
|
|
228
262
|
@index.add(target.name.to_s.encode(Encoding::UTF_8), loc(node, "worker_declaration"), selected: !@presence_only, ambiguous: true)
|
|
229
263
|
end
|
|
230
264
|
end
|
|
265
|
+
return if @presence_only
|
|
266
|
+
unless valid
|
|
267
|
+
leaf = name&.split("::")&.last || target.name.to_s.encode(Encoding::UTF_8)
|
|
268
|
+
@ambiguous_bindings << ConstantBinding.new(leaf, "write", loc(node, "worker_declaration"),
|
|
269
|
+
[scope, self.class.normalize(node.location.slice), self.class.normalize((statement || node).location.slice)])
|
|
270
|
+
end
|
|
271
|
+
return if name.nil?
|
|
272
|
+
plain = node.is_a?(Prism::ConstantWriteNode) || node.is_a?(Prism::ConstantPathWriteNode)
|
|
273
|
+
reference = self.class.constant(node.value) if node.respond_to?(:value)
|
|
274
|
+
supported = plain && valid && alias_context && reference &&
|
|
275
|
+
(reference.last || reference.first.length == 1 || namespace.empty?)
|
|
276
|
+
location = loc(node, "worker_declaration")
|
|
277
|
+
parts = [scope, self.class.normalize(node.location.slice), self.class.normalize((statement || node).location.slice), reference]
|
|
278
|
+
@bindings << ConstantBinding.new(name, "write", location, parts)
|
|
279
|
+
@alias_candidates << AliasCandidate.new(name, reference && reference.first.join("::"), reference && reference.last,
|
|
280
|
+
namespace, !!supported, location, parts)
|
|
231
281
|
end
|
|
232
282
|
|
|
233
283
|
def sidekiq_include?(node)
|
|
@@ -421,7 +471,7 @@ module Jobcompat
|
|
|
421
471
|
selected = entries.select { |entry| @config.scan?(entry.path) }
|
|
422
472
|
tracked = entries.select { |entry| entry.path.end_with?(".rb") }
|
|
423
473
|
index = DefinedConstantIndex.new
|
|
424
|
-
fragments, calls, unknowns, errors = [], [], [], []
|
|
474
|
+
fragments, calls, unknowns, errors, bindings, alias_candidates, ambiguous_bindings = [], [], [], [], [], [], []
|
|
425
475
|
@repository.each_blob(selected) do |entry, bytes|
|
|
426
476
|
path = entry.path
|
|
427
477
|
begin
|
|
@@ -429,6 +479,9 @@ module Jobcompat
|
|
|
429
479
|
fragments.concat(result.fragments)
|
|
430
480
|
calls.concat(result.calls)
|
|
431
481
|
unknowns.concat(result.unknowns)
|
|
482
|
+
bindings.concat(result.bindings)
|
|
483
|
+
alias_candidates.concat(result.alias_candidates)
|
|
484
|
+
ambiguous_bindings.concat(result.ambiguous_bindings)
|
|
432
485
|
rescue Error => error
|
|
433
486
|
errors << error
|
|
434
487
|
end
|
|
@@ -470,8 +523,9 @@ module Jobcompat
|
|
|
470
523
|
workers[name] = Contract.new(name, required, rest ? nil : required + optional, forwarding ? "forwarding" : "positional", signature, nil, locations, declarations, fingerprint_parts)
|
|
471
524
|
end
|
|
472
525
|
end
|
|
473
|
-
|
|
474
|
-
|
|
526
|
+
aliases = AliasResolver.new(workers, bindings, alias_candidates, ambiguous_bindings).resolve
|
|
527
|
+
RevisionSnapshot.new(label: label, ref: ref, sha: sha, workers: workers, aliases: aliases, calls: calls, unknowns: unknowns, index: index,
|
|
528
|
+
files_scanned: selected.length, tracked_ruby: tracked, bindings: bindings)
|
|
475
529
|
end
|
|
476
530
|
end
|
|
477
531
|
end
|
data/lib/jobcompat/cli.rb
CHANGED
|
@@ -9,6 +9,7 @@ module Jobcompat
|
|
|
9
9
|
def initialize(args, stdout, stderr, directory)
|
|
10
10
|
@args, @stdout, @stderr, @directory = args.dup, stdout, stderr, directory
|
|
11
11
|
@base, @head, @format, @config_path = nil, "HEAD", "text", nil
|
|
12
|
+
@explain = false
|
|
12
13
|
@base_sha = @head_sha = @loaded_config = nil
|
|
13
14
|
end
|
|
14
15
|
|
|
@@ -22,12 +23,14 @@ module Jobcompat
|
|
|
22
23
|
options.on("--head REF", "Head commit-ish (default HEAD)") { |value| @head = value }
|
|
23
24
|
options.on("--format FORMAT", "text or json") { |value| @format = value }
|
|
24
25
|
options.on("--config PATH", "Config YAML path") { |value| @config_path = value }
|
|
26
|
+
options.on("--explain", "Explain the evidence behind each finding (text only)") { @explain = true }
|
|
25
27
|
options.on("-h", "--help", "Show help") { @stdout.puts(options); return 0 }
|
|
26
28
|
end
|
|
27
29
|
parser.parse!(@args)
|
|
28
30
|
raise Error.new("Unexpected arguments: #{@args.join(' ')}", category: "config_error") unless @args.empty?
|
|
29
31
|
raise Error.new("--base is required", category: "config_error") unless @base && !@base.empty?
|
|
30
32
|
raise Error.new("--format must be text or json", category: "config_error") unless %w[text json].include?(@format)
|
|
33
|
+
raise Error.new("--explain is supported only with --format text", category: "config_error") if @explain && @format != "text"
|
|
31
34
|
repository = GitRepository.new(@directory)
|
|
32
35
|
config = Config.load(root: repository.root, explicit: @config_path, invocation_dir: @directory)
|
|
33
36
|
@loaded_config = config.display_path
|
|
@@ -57,7 +60,7 @@ module Jobcompat
|
|
|
57
60
|
requests = engine.presence_requests
|
|
58
61
|
head.check_presence(requests["head"], repository, config)
|
|
59
62
|
base.check_presence(requests["base"], repository, config)
|
|
60
|
-
result = engine.evaluate
|
|
63
|
+
result = engine.evaluate(with_explanations: @explain)
|
|
61
64
|
findings, suppressions = config.suppressions(result[:findings])
|
|
62
65
|
summary = {
|
|
63
66
|
errors: findings.count { |item| item[:severity] == "error" }, warnings: findings.count { |item| item[:severity] == "warning" },
|
|
@@ -67,7 +70,13 @@ module Jobcompat
|
|
|
67
70
|
enqueue_calls: {base: base.calls.length, head: head.calls.length,
|
|
68
71
|
unknown: engine.calls.values.flatten.count { |call| call[:reason] }}
|
|
69
72
|
}
|
|
70
|
-
|
|
73
|
+
completed = envelope("completed", findings, suppressions, result[:workers], [], summary)
|
|
74
|
+
output = if @format == "json"
|
|
75
|
+
Formatter.json(completed)
|
|
76
|
+
else
|
|
77
|
+
Formatter.text(completed, explanations: @explain ? result[:explanations] : nil)
|
|
78
|
+
end
|
|
79
|
+
@stdout.write(output)
|
|
71
80
|
summary[:errors].positive? ? 1 : 0
|
|
72
81
|
rescue OptionParser::ParseError => error
|
|
73
82
|
failure(Error.new(error.message, category: "config_error"))
|
|
@@ -98,7 +107,7 @@ module Jobcompat
|
|
|
98
107
|
end
|
|
99
108
|
|
|
100
109
|
def envelope(status, findings, suppressions, workers, diagnostics, summary)
|
|
101
|
-
{schema_version:
|
|
110
|
+
{schema_version: 3, tool: {name: "jobcompat", version: VERSION}, status: status,
|
|
102
111
|
comparison: {deployment_model: "rolling", base: {ref: @base, sha: @base_sha}, head: {ref: @head, sha: @head_sha}},
|
|
103
112
|
configuration: {path: @loaded_config}, findings: findings, suppressions: suppressions, workers: workers,
|
|
104
113
|
diagnostics: diagnostics, summary: summary}
|
data/lib/jobcompat/engine.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Jobcompat
|
|
|
4
4
|
REVISIONS = %w[base head].freeze
|
|
5
5
|
TITLES = {
|
|
6
6
|
"JC001" => "Old payload rejected by new worker", "JC002" => "New payload rejected by old worker",
|
|
7
|
-
"JC003" => "Current producer/consumer mismatch", "JC004" => "Worker
|
|
7
|
+
"JC003" => "Current producer/consumer mismatch", "JC004" => "Worker identity absent from HEAD source",
|
|
8
8
|
"JC005" => "New worker enqueued before old fleet can understand it", "JC006" => "Worker contract narrowed without sufficient producer evidence",
|
|
9
9
|
"JC007" => "Compatibility could not be proven"
|
|
10
10
|
}.freeze
|
|
@@ -13,13 +13,18 @@ module Jobcompat
|
|
|
13
13
|
|
|
14
14
|
def initialize(base, head)
|
|
15
15
|
@base, @head = base, head
|
|
16
|
-
@
|
|
17
|
-
@calls = {"base" => resolve(base
|
|
16
|
+
@lookup_names = (base.workers.keys + head.workers.keys + base.aliases.keys + head.aliases.keys).uniq
|
|
17
|
+
@calls = {"base" => resolve(base), "head" => resolve(head)}
|
|
18
|
+
referenced = calls.values.flatten.flat_map { |call| [call[:worker], call[:attributed_worker]] }.compact
|
|
19
|
+
shared_aliases = (base.aliases.keys & head.aliases.keys).select { |name| base.aliases[name].static_reference || head.aliases[name].static_reference }
|
|
20
|
+
@names = (base.workers.keys + head.workers.keys +
|
|
21
|
+
base.aliases.values.select(&:resolved?).map(&:name) +
|
|
22
|
+
shared_aliases + referenced).uniq.sort
|
|
18
23
|
@calls_by_worker = {"base" => calls["base"].group_by { |call| call[:worker] },
|
|
19
24
|
"head" => calls["head"].group_by { |call| call[:worker] }}
|
|
20
25
|
@presence_requests = {
|
|
21
|
-
"head" =>
|
|
22
|
-
"base" =>
|
|
26
|
+
"head" => @names.select { |name| base.consumer(name) && !head.consumer(name) },
|
|
27
|
+
"base" => @names.select { |name| head.consumer(name) && !base.consumer(name) && @calls_by_worker["head"].key?(name) }
|
|
23
28
|
}
|
|
24
29
|
end
|
|
25
30
|
|
|
@@ -27,33 +32,44 @@ module Jobcompat
|
|
|
27
32
|
@presence_requests
|
|
28
33
|
end
|
|
29
34
|
|
|
30
|
-
def evaluate
|
|
35
|
+
def evaluate(with_explanations: false)
|
|
31
36
|
findings = []
|
|
37
|
+
explanations = {}.compare_by_identity
|
|
32
38
|
workers = @names.map do |name|
|
|
33
|
-
b = base.
|
|
34
|
-
h = head.
|
|
39
|
+
b = base.consumer(name)
|
|
40
|
+
h = head.consumer(name)
|
|
35
41
|
bc = @calls_by_worker["base"].fetch(name, [])
|
|
36
42
|
hc = @calls_by_worker["head"].fetch(name, [])
|
|
37
43
|
base_presence = presence(base, name, b, presence_requests["base"].include?(name))
|
|
38
44
|
head_presence = presence(head, name, h, presence_requests["head"].include?(name))
|
|
39
45
|
if b && !h
|
|
40
46
|
if head_presence == "absent"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
item = finding("JC004", name, ["base", "head"], ["base_to_head"], nil, nil,
|
|
48
|
+
"The serialized worker identity is absent from HEAD tracked Ruby source.",
|
|
49
|
+
"Queued, retried, or scheduled jobs may still reference this class name.",
|
|
50
|
+
["Keep the worker class or a supported compatibility alias until retained jobs can no longer run."],
|
|
51
|
+
b.declaration_locations + contract_proof(b) + producer_proof(hc))
|
|
52
|
+
findings << item
|
|
53
|
+
explanations[item] = ExplanationBuilder.jc004(worker: name, base_contract: b, head_producers: hc,
|
|
54
|
+
aliases: alias_provenance(name)) if with_explanations
|
|
55
|
+
elsif !head.aliases.key?(name)
|
|
56
|
+
item = presence_unknown(name, head_presence, b.declaration_locations + head.index.locations(name), "base_to_head", "head")
|
|
57
|
+
findings << item
|
|
58
|
+
explanations[item] = ExplanationBuilder.presence_unknown(worker: name, status: head_presence, revision: "head") if with_explanations
|
|
48
59
|
end
|
|
49
60
|
elsif h && !b && !hc.empty?
|
|
50
61
|
if base_presence == "absent"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
item = finding("JC005", name, ["base", "head"], ["head_to_base"], nil, nil,
|
|
63
|
+
"HEAD can enqueue a serialized worker identity absent from base tracked Ruby source.",
|
|
64
|
+
"If an old Sidekiq process can consume this job during the rolling deployment, it cannot resolve the new worker class.",
|
|
65
|
+
["Deploy the worker class or compatibility alias before activating its producer."], h.declaration_locations + producer_proof(hc))
|
|
66
|
+
findings << item
|
|
67
|
+
explanations[item] = ExplanationBuilder.jc005(worker: name, head_contract: h, head_calls: hc,
|
|
68
|
+
aliases: alias_provenance(name)) if with_explanations
|
|
69
|
+
elsif !base.aliases.key?(name)
|
|
70
|
+
item = presence_unknown(name, base_presence, h.declaration_locations + base.index.locations(name), "head_to_base", "base")
|
|
71
|
+
findings << item
|
|
72
|
+
explanations[item] = ExplanationBuilder.presence_unknown(worker: name, status: base_presence, revision: "base") if with_explanations
|
|
57
73
|
end
|
|
58
74
|
end
|
|
59
75
|
|
|
@@ -66,11 +82,14 @@ module Jobcompat
|
|
|
66
82
|
head_same = head_groups.fetch(arity, []).select { |call| !h.accepts?(call[:arity]) }
|
|
67
83
|
directions = ["base_to_head"]
|
|
68
84
|
directions << "head_to_head" unless head_same.empty?
|
|
69
|
-
locations = group
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
85
|
+
locations = producer_proof(group) + contract_proof(b) + contract_proof(h) + producer_proof(head_same)
|
|
86
|
+
item = finding("JC001", name, head_same.empty? ? ["base", "head"] : ["base", "head"], directions, arity, nil,
|
|
87
|
+
"Base emits #{arity} #{argument_word(arity)}, but the HEAD worker accepts #{h.display_range}.",
|
|
88
|
+
"Jobs queued by the base revision may fail after deployment.",
|
|
89
|
+
["Make the HEAD worker accept old payloads.", "Narrow only after queue, retry, and schedule retention is handled."], locations)
|
|
90
|
+
findings << item
|
|
91
|
+
explanations[item] = ExplanationBuilder.jc001(arity: arity, base_contract: b, head_contract: h, head_witness: !head_same.empty?,
|
|
92
|
+
aliases: alias_provenance(name)) if with_explanations
|
|
74
93
|
proven_narrow = true
|
|
75
94
|
end
|
|
76
95
|
end
|
|
@@ -79,17 +98,23 @@ module Jobcompat
|
|
|
79
98
|
head_groups.sort.each do |arity, group|
|
|
80
99
|
if !h.accepts?(arity)
|
|
81
100
|
next if findings.any? { |item| item[:rule_id] == "JC001" && item[:worker] == name && item[:payload_arity] == arity }
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
101
|
+
item = finding("JC003", name, ["head"], ["head_to_head"], arity, nil,
|
|
102
|
+
"HEAD emits #{arity} #{argument_word(arity)}, but its worker accepts #{h.display_range}.",
|
|
103
|
+
"Current HEAD jobs can fail when executed.", ["Align the enqueue payload with the HEAD perform signature."],
|
|
104
|
+
producer_proof(group) + contract_proof(h))
|
|
105
|
+
findings << item
|
|
106
|
+
explanations[item] = ExplanationBuilder.jc003(arity: arity, head_contract: h,
|
|
107
|
+
aliases: alias_provenance(name, head)) if with_explanations
|
|
86
108
|
current_removed_witness ||= b&.known? && b.accepts?(arity)
|
|
87
109
|
elsif b&.known? && !b.accepts?(arity)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
110
|
+
item = finding("JC002", name, ["base", "head"], ["head_to_base"], arity, nil,
|
|
111
|
+
"HEAD emits #{arity} #{argument_word(arity)}, but the base worker accepts #{b.display_range}.",
|
|
112
|
+
"A new producer can enqueue work that an old worker cannot execute during a rolling deploy.",
|
|
113
|
+
["Deploy the optional worker argument first.", "Start enqueueing the new argument in a later release."],
|
|
114
|
+
producer_proof(group) + contract_proof(b) + contract_proof(h))
|
|
115
|
+
findings << item
|
|
116
|
+
explanations[item] = ExplanationBuilder.jc002(arity: arity, base_contract: b, head_contract: h,
|
|
117
|
+
aliases: alias_provenance(name)) if with_explanations
|
|
93
118
|
end
|
|
94
119
|
end
|
|
95
120
|
end
|
|
@@ -97,16 +122,20 @@ module Jobcompat
|
|
|
97
122
|
unless h.superset_of?(b) || proven_narrow || current_removed_witness
|
|
98
123
|
removed = removed_arity_ranges(b, h)
|
|
99
124
|
label = removed.length == 1 && removed.first.match?(/\A\d+\z/) ? "arity" : "arities"
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
125
|
+
item = finding("JC006", name, ["base", "head"], ["base_to_head"], nil, nil,
|
|
126
|
+
"The HEAD worker removed #{label} #{removed.join(', ')} from the base contract; no qualifying producer witness was found.",
|
|
127
|
+
"No repository producer callsite found does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists.",
|
|
128
|
+
["Keep the broader signature through the retention window, or document a proven drain."],
|
|
129
|
+
contract_proof(b) + contract_proof(h))
|
|
130
|
+
findings << item
|
|
131
|
+
explanations[item] = ExplanationBuilder.jc006(worker: name, base_contract: b, head_contract: h, removed: removed,
|
|
132
|
+
aliases: alias_provenance(name)) if with_explanations
|
|
105
133
|
end
|
|
106
134
|
end
|
|
107
135
|
|
|
108
136
|
{
|
|
109
137
|
name: name, base_presence: base_presence, head_presence: head_presence,
|
|
138
|
+
base_alias: base.aliases[name]&.as_json, head_alias: head.aliases[name]&.as_json,
|
|
110
139
|
base_contract: b&.as_json, head_contract: h&.as_json,
|
|
111
140
|
producer_arities: {base: bc.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort,
|
|
112
141
|
head: hc.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort,
|
|
@@ -115,42 +144,92 @@ module Jobcompat
|
|
|
115
144
|
head_to_base: cell(hc, b, base_presence), head_to_head: cell(hc, h, head_presence)}
|
|
116
145
|
}
|
|
117
146
|
end
|
|
118
|
-
findings.concat(unknown_findings)
|
|
147
|
+
findings.concat(unknown_findings(explanations: with_explanations ? explanations : nil))
|
|
119
148
|
findings = findings.sort_by { |item| finding_sort_key(item) }
|
|
120
|
-
{findings: findings, workers: workers}
|
|
149
|
+
result = {findings: findings, workers: workers}
|
|
150
|
+
result[:explanations] = explanations if with_explanations
|
|
151
|
+
result
|
|
121
152
|
end
|
|
122
153
|
|
|
123
154
|
private
|
|
124
155
|
|
|
125
|
-
def resolve(
|
|
126
|
-
|
|
156
|
+
def resolve(snapshot)
|
|
157
|
+
snapshot.calls.sort_by { |call| [call.path, call.location.line, call.location.column, call.method,
|
|
127
158
|
call.arity.nil? ? Float::INFINITY : call.arity, call.unknown_reason.to_s] }.filter_map do |call|
|
|
128
159
|
worker = nil
|
|
160
|
+
attributed_worker = nil
|
|
161
|
+
binding = nil
|
|
162
|
+
binding_locations = []
|
|
163
|
+
identity_reason = nil
|
|
129
164
|
if call.receiver
|
|
130
165
|
candidates = if call.resolution_mode == "exact_string" || call.root
|
|
131
166
|
[call.receiver]
|
|
132
|
-
elsif call.namespace.nil?
|
|
133
|
-
[]
|
|
134
167
|
else
|
|
135
|
-
(call.
|
|
168
|
+
ConstantLookup.candidates(call.receiver, false, call.namespace)
|
|
136
169
|
end
|
|
137
|
-
worker = candidates
|
|
170
|
+
worker = producer_identity(snapshot, candidates, call.resolution_mode)
|
|
171
|
+
attributed_worker = worker
|
|
172
|
+
if worker && call.resolution_mode != "exact_string" && snapshot.aliases.key?(worker)
|
|
173
|
+
binding = snapshot.aliases[worker]
|
|
174
|
+
worker = binding.resolved? ? binding.target : nil if binding
|
|
175
|
+
binding_locations = snapshot.workers.fetch(binding.target).locations if binding.resolved?
|
|
176
|
+
elsif worker && alias_lookup?(candidates, call.resolution_mode) && snapshot.bindings.key?(worker) && !snapshot.workers.key?(worker)
|
|
177
|
+
binding_locations = snapshot.bindings.fetch(worker).map(&:location)
|
|
178
|
+
identity_reason = "worker_not_recognized"
|
|
179
|
+
worker = nil
|
|
180
|
+
elsif worker && call.resolution_mode != "exact_string" && !snapshot.workers.key?(worker) && (base.aliases.key?(worker) || head.aliases.key?(worker))
|
|
181
|
+
worker = nil
|
|
182
|
+
end
|
|
138
183
|
client_target = %w[client_constant exact_string].include?(call.resolution_mode)
|
|
139
|
-
next unless worker || client_target || (!call.root && call.namespace.nil?)
|
|
184
|
+
next unless worker || attributed_worker || client_target || (!call.root && call.namespace.nil?)
|
|
140
185
|
end
|
|
141
|
-
|
|
186
|
+
identity_reason ||= binding && !binding.resolved? ? binding.unknown_reason : nil
|
|
187
|
+
identity_reason ||= "alias_target_unresolved" if worker.nil? && attributed_worker
|
|
188
|
+
reason = identity_reason || call.unknown_reason
|
|
142
189
|
reason ||= "dynamic_client_class" if call.receiver && worker.nil? && %w[client_constant exact_string].include?(call.resolution_mode)
|
|
143
190
|
reason ||= call.namespace.nil? ? "unsupported_constant_path" : "dynamic_receiver" if worker.nil?
|
|
144
|
-
{worker: worker,
|
|
191
|
+
{worker: worker, attributed_worker: attributed_worker, identity_known: !worker.nil?, identity_reason: identity_reason,
|
|
192
|
+
location: call.location, locations: [call.location] + (binding&.locations || []) + binding_locations,
|
|
193
|
+
arity: call.arity, reason: reason, raw: call}
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def producer_identity(snapshot, candidates, mode)
|
|
198
|
+
if alias_lookup?(candidates, mode)
|
|
199
|
+
local = candidates.find { |name| snapshot.bindings.key?(name) || snapshot.workers.key?(name) || snapshot.aliases.key?(name) }
|
|
200
|
+
return local if local
|
|
145
201
|
end
|
|
202
|
+
candidates.find { |name| @lookup_names.include?(name) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def alias_lookup?(candidates, mode)
|
|
206
|
+
mode != "exact_string" && candidates.any? { |name| base.aliases.key?(name) || head.aliases.key?(name) }
|
|
146
207
|
end
|
|
147
208
|
|
|
148
209
|
def presence(snapshot, name, contract, queried)
|
|
149
|
-
return
|
|
210
|
+
return snapshot.presence(name) if contract || snapshot.aliases.key?(name)
|
|
150
211
|
return "not_checked" unless queried
|
|
151
212
|
snapshot.index.status(name)
|
|
152
213
|
end
|
|
153
214
|
|
|
215
|
+
def producer_proof(group) = group.flat_map { |call| call[:locations] }
|
|
216
|
+
|
|
217
|
+
def alias_provenance(name, *snapshots)
|
|
218
|
+
snapshots = [base, head] if snapshots.empty?
|
|
219
|
+
snapshots.filter_map do |snapshot|
|
|
220
|
+
binding = snapshot.aliases[name]
|
|
221
|
+
next unless binding
|
|
222
|
+
chain = binding.chain.join(" -> ")
|
|
223
|
+
binding.resolved? ? "Alias (#{snapshot.label.upcase}): #{chain} (resolved)." :
|
|
224
|
+
"Alias (#{snapshot.label.upcase}): #{chain.length > 1 ? chain : "unresolved"} (#{binding.unknown_reason})."
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def contract_proof(contract)
|
|
229
|
+
return contract.locations if base.aliases.key?(contract.name) || head.aliases.key?(contract.name)
|
|
230
|
+
contract.locations.select { |location| location.role == "consumer" }
|
|
231
|
+
end
|
|
232
|
+
|
|
154
233
|
def cell(producers, consumer, status)
|
|
155
234
|
return "not_applicable" if producers.empty?
|
|
156
235
|
known = producers.filter_map { |call| call[:arity] unless call[:reason] }
|
|
@@ -193,25 +272,36 @@ module Jobcompat
|
|
|
193
272
|
["Restore a directly recognized worker declaration, or inspect the deployment manually."], locations)
|
|
194
273
|
end
|
|
195
274
|
|
|
196
|
-
def unknown_findings
|
|
275
|
+
def unknown_findings(explanations: nil)
|
|
197
276
|
roots = []
|
|
198
277
|
[base, head].each do |snapshot|
|
|
199
|
-
|
|
200
|
-
|
|
278
|
+
@names.each do |name|
|
|
279
|
+
contract = snapshot.consumer(name)
|
|
280
|
+
next if !contract || contract.known?
|
|
201
281
|
parts = ["consumer_contract", contract.unknown_reason, name, contract.fingerprint_parts]
|
|
202
282
|
roots << {revision: snapshot.label, kind: "consumer_contract", reason: contract.unknown_reason, worker: name,
|
|
203
283
|
locations: contract.locations, parts: parts, offset: contract.locations.first&.line || 0}
|
|
204
284
|
end
|
|
285
|
+
snapshot.aliases.each do |name, binding|
|
|
286
|
+
next if binding.resolved? || !@names.include?(name)
|
|
287
|
+
producer_locations = calls[snapshot.label].select { |call| call[:identity_reason] && call[:attributed_worker] == name }.flat_map { |call| call[:locations] }
|
|
288
|
+
roots << {revision: snapshot.label, kind: "consumer_alias", reason: binding.unknown_reason, worker: name,
|
|
289
|
+
locations: binding.locations + producer_locations,
|
|
290
|
+
parts: ["consumer_alias", binding.unknown_reason, name, binding.fingerprint_parts, binding.chain],
|
|
291
|
+
chain: binding.chain, offset: 0}
|
|
292
|
+
end
|
|
205
293
|
snapshot.unknowns.each do |item|
|
|
206
294
|
roots << {revision: snapshot.label, kind: item.kind, reason: item.reason, worker: item.worker,
|
|
207
295
|
locations: item.locations, parts: [item.kind, item.reason, item.worker, item.fingerprint_parts], offset: item.locations.first&.line || 0}
|
|
208
296
|
end
|
|
209
297
|
calls[snapshot.label].each do |call|
|
|
210
298
|
next unless call[:reason]
|
|
299
|
+
next if call[:identity_reason] && snapshot.aliases.key?(call[:attributed_worker])
|
|
211
300
|
raw = call[:raw]
|
|
212
|
-
kind = %w[dynamic_receiver unsupported_constant_path safe_navigation_receiver].include?(call[:reason]) ? "producer_receiver" : "producer_arity"
|
|
213
|
-
|
|
214
|
-
|
|
301
|
+
kind = call[:identity_reason] || %w[dynamic_receiver unsupported_constant_path safe_navigation_receiver].include?(call[:reason]) ? "producer_receiver" : "producer_arity"
|
|
302
|
+
diagnostic_worker = call[:worker] || (call[:identity_reason] && call[:attributed_worker])
|
|
303
|
+
roots << {revision: snapshot.label, kind: kind, reason: call[:reason], worker: diagnostic_worker,
|
|
304
|
+
locations: call[:locations], parts: [kind, call[:reason], diagnostic_worker, raw.fingerprint_parts],
|
|
215
305
|
offset: [call[:location].line, call[:location].column]}
|
|
216
306
|
end
|
|
217
307
|
end
|
|
@@ -227,17 +317,34 @@ module Jobcompat
|
|
|
227
317
|
when ["producer_arity", "base"], ["producer_receiver", "base"] then ["base_to_head"]
|
|
228
318
|
when ["producer_arity", "head"], ["producer_receiver", "head"] then ["head_to_base", "head_to_head"]
|
|
229
319
|
when ["consumer_contract", "base"], ["worker_identity", "base"] then ["head_to_base"]
|
|
320
|
+
when ["consumer_alias", "base"] then ["head_to_base"]
|
|
321
|
+
when ["consumer_alias", "head"]
|
|
322
|
+
@calls_by_worker["head"].key?(root[:worker]) ? ["base_to_head", "head_to_head"] : ["base_to_head"]
|
|
230
323
|
else ["base_to_head", "head_to_head"]
|
|
231
324
|
end
|
|
232
325
|
end.uniq
|
|
233
326
|
if first[:kind] == "producer_arity" && first[:worker]
|
|
234
|
-
head_only_new = head.
|
|
327
|
+
head_only_new = head.consumer(first[:worker]) && !base.consumer(first[:worker])
|
|
235
328
|
directions.delete("head_to_base") if head_only_new && group.any? { |root| root[:revision] == "head" } && base.index.status(first[:worker]) == "absent"
|
|
236
329
|
end
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
330
|
+
alias_reason = %w[alias_target_unresolved alias_cycle alias_binding_conflict unsupported_alias_assignment].include?(first[:reason])
|
|
331
|
+
message = alias_reason ? alias_message(first[:worker], first[:reason]) : "#{first[:reason]} prevents a complete positional compatibility check."
|
|
332
|
+
risk = alias_reason ? "Compatibility for jobs serialized as #{first[:worker]} cannot be proven from selected source." : "The affected producer or consumer contract is unknown."
|
|
333
|
+
remediation = alias_reason ? ["Use one explicit static alias if it matches runtime behavior, or inspect the deployment manually."] : ["Use supported explicit syntax or inspect this call manually."]
|
|
334
|
+
item = finding("JC007", first[:worker], group.map { |root| root[:revision] }.uniq, directions, nil, first[:reason],
|
|
335
|
+
message, risk, remediation,
|
|
336
|
+
group.flat_map { |root| root[:locations] })
|
|
337
|
+
explanations[item] = ExplanationBuilder.unknown_group(roots: group, message: message) if explanations
|
|
338
|
+
item
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def alias_message(name, reason)
|
|
343
|
+
case reason
|
|
344
|
+
when "alias_target_unresolved" then "#{name} is bound as a compatibility alias, but its target cannot be resolved to a supported Sidekiq worker."
|
|
345
|
+
when "alias_cycle" then "Compatibility alias resolution for #{name} enters a constant-alias cycle."
|
|
346
|
+
when "alias_binding_conflict" then "#{name} has multiple or conflicting constant bindings, so its runtime job class cannot be proven."
|
|
347
|
+
when "unsupported_alias_assignment" then "#{name} is assigned through syntax or control flow that does not prove one unconditional static compatibility alias."
|
|
241
348
|
end
|
|
242
349
|
end
|
|
243
350
|
|