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.
@@ -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.reject { |name| index.status(name, recognized: workers.key?(name)) != "absent" }
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
- walk(node.body, child_namespace, new_scope, nil) if node.body
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
- @index.add((prefix + [node.name.to_s.encode(Encoding::UTF_8)]).join("::"), loc(node, "worker_declaration"),
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
- RevisionSnapshot.new(label: label, ref: ref, sha: sha, workers: workers, calls: calls, unknowns: unknowns, index: index,
474
- files_scanned: selected.length, tracked_ruby: tracked)
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
- @stdout.write(Formatter.public_send(@format, envelope("completed", findings, suppressions, result[:workers], [], summary)))
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: 2, tool: {name: "jobcompat", version: VERSION}, status: status,
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}
@@ -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 class absent from HEAD source",
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
- @names = (base.workers.keys + head.workers.keys).uniq.sort
17
- @calls = {"base" => resolve(base.calls), "head" => resolve(head.calls)}
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" => (base.workers.keys - head.workers.keys).sort,
22
- "base" => (head.workers.keys - base.workers.keys).select { |name| @calls_by_worker["head"].key?(name) }.sort
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.workers[name]
34
- h = head.workers[name]
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
- findings << finding("JC004", name, ["base", "head"], ["base_to_head"], nil, nil,
42
- "The worker class is absent from HEAD tracked Ruby source.",
43
- "Queued, retried, or scheduled jobs may still reference this class name.",
44
- ["Keep a compatibility class until retained jobs can no longer run."],
45
- b.declaration_locations + b.locations.select { |loc| loc.role == "consumer" } + hc.map { |call| call[:location] })
46
- else
47
- findings << presence_unknown(name, head_presence, b.declaration_locations + head.index.locations(name), "base_to_head", "head")
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
- findings << finding("JC005", name, ["base", "head"], ["head_to_base"], nil, nil,
52
- "HEAD can enqueue a worker class absent from base tracked Ruby source.",
53
- "If an old Sidekiq process can consume this job during the rolling deployment, it cannot resolve the new worker class.",
54
- ["Deploy the worker class before activating its producer."], h.declaration_locations + hc.map { |call| call[:location] })
55
- else
56
- findings << presence_unknown(name, base_presence, h.declaration_locations + base.index.locations(name), "head_to_base", "base")
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.map { |call| call[:location] } + b.locations.select { |loc| loc.role == "consumer" } + h.locations.select { |loc| loc.role == "consumer" } + head_same.map { |call| call[:location] }
70
- findings << finding("JC001", name, head_same.empty? ? ["base", "head"] : ["base", "head"], directions, arity, nil,
71
- "Base emits #{arity} #{argument_word(arity)}, but the HEAD worker accepts #{h.display_range}.",
72
- "Jobs queued by the base revision may fail after deployment.",
73
- ["Make the HEAD worker accept old payloads.", "Narrow only after queue, retry, and schedule retention is handled."], locations)
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
- findings << finding("JC003", name, ["head"], ["head_to_head"], arity, nil,
83
- "HEAD emits #{arity} #{argument_word(arity)}, but its worker accepts #{h.display_range}.",
84
- "Current HEAD jobs can fail when executed.", ["Align the enqueue payload with the HEAD perform signature."],
85
- group.map { |call| call[:location] } + h.locations.select { |loc| loc.role == "consumer" })
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
- findings << finding("JC002", name, ["base", "head"], ["head_to_base"], arity, nil,
89
- "HEAD emits #{arity} #{argument_word(arity)}, but the base worker accepts #{b.display_range}.",
90
- "A new producer can enqueue work that an old worker cannot execute during a rolling deploy.",
91
- ["Deploy the optional worker argument first.", "Start enqueueing the new argument in a later release."],
92
- group.map { |call| call[:location] } + b.locations.select { |loc| loc.role == "consumer" } + h.locations.select { |loc| loc.role == "consumer" })
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
- findings << finding("JC006", name, ["base", "head"], ["base_to_head"], nil, nil,
101
- "The HEAD worker removed #{label} #{removed.join(', ')} from the base contract; no qualifying producer witness was found.",
102
- "No repository producer callsite found does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists.",
103
- ["Keep the broader signature through the retention window, or document a proven drain."],
104
- b.locations.select { |loc| loc.role == "consumer" } + h.locations.select { |loc| loc.role == "consumer" })
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(raw_calls)
126
- raw_calls.sort_by { |call| [call.path, call.location.line, call.location.column, call.method,
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.namespace.reverse.map { |prefix| "#{prefix}::#{call.receiver}" } + [call.receiver]).uniq
168
+ ConstantLookup.candidates(call.receiver, false, call.namespace)
136
169
  end
137
- worker = candidates.find { |name| @names.include?(name) }
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
- reason = call.unknown_reason
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, location: call.location, arity: call.arity, reason: reason, raw: call}
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 "recognized_worker" if contract
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
- snapshot.workers.each do |name, contract|
200
- next if contract.known?
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
- roots << {revision: snapshot.label, kind: kind, reason: call[:reason], worker: call[:worker],
214
- locations: [call[:location]], parts: [kind, call[:reason], call[:worker], raw.fingerprint_parts],
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.workers.key?(first[:worker]) && !base.workers.key?(first[:worker])
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
- finding("JC007", first[:worker], group.map { |root| root[:revision] }.uniq, directions, nil, first[:reason],
238
- "#{first[:reason]} prevents a complete positional compatibility check.",
239
- "The affected producer or consumer contract is unknown.", ["Use supported explicit syntax or inspect this call manually."],
240
- group.flat_map { |root| root[:locations] })
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