jobcompat 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/jobcompat/cli.rb CHANGED
@@ -98,7 +98,7 @@ module Jobcompat
98
98
  end
99
99
 
100
100
  def envelope(status, findings, suppressions, workers, diagnostics, summary)
101
- {schema_version: 2, tool: {name: "jobcompat", version: VERSION}, status: status,
101
+ {schema_version: 3, tool: {name: "jobcompat", version: VERSION}, status: status,
102
102
  comparison: {deployment_model: "rolling", base: {ref: @base, sha: @base_sha}, head: {ref: @head, sha: @head_sha}},
103
103
  configuration: {path: @loaded_config}, findings: findings, suppressions: suppressions, workers: workers,
104
104
  diagnostics: diagnostics, summary: summary}
@@ -1,4 +1,5 @@
1
1
  require "psych"
2
+ require_relative "canonical_worker_name"
2
3
 
3
4
  module Jobcompat
4
5
  class Config
@@ -36,12 +37,7 @@ module Jobcompat
36
37
  fields!(item, %w[rule worker reason], "ignore[#{index}]")
37
38
  raise Error.new("ignore[#{index}] must have rule, worker, reason", category: "config_error") unless item.keys.sort == %w[reason rule worker]
38
39
  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
40
+ raise Error.new("ignore[#{index}].worker is invalid", category: "config_error") unless CanonicalWorkerName.parse(item["worker"])
45
41
  raise Error.new("ignore[#{index}].reason must be non-blank", category: "config_error") unless item["reason"].is_a?(String) && !item["reason"].strip.empty?
46
42
  key = [item["rule"], item["worker"]]
47
43
  raise Error.new("duplicate ignore for #{key.join(' ')}", category: "config_error") if seen[key]
@@ -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
 
@@ -30,8 +35,8 @@ module Jobcompat
30
35
  def evaluate
31
36
  findings = []
32
37
  workers = @names.map do |name|
33
- b = base.workers[name]
34
- h = head.workers[name]
38
+ b = base.consumer(name)
39
+ h = head.consumer(name)
35
40
  bc = @calls_by_worker["base"].fetch(name, [])
36
41
  hc = @calls_by_worker["head"].fetch(name, [])
37
42
  base_presence = presence(base, name, b, presence_requests["base"].include?(name))
@@ -39,21 +44,21 @@ module Jobcompat
39
44
  if b && !h
40
45
  if head_presence == "absent"
41
46
  findings << finding("JC004", name, ["base", "head"], ["base_to_head"], nil, nil,
42
- "The worker class is absent from HEAD tracked Ruby source.",
47
+ "The serialized worker identity is absent from HEAD tracked Ruby source.",
43
48
  "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] })
49
+ ["Keep the worker class or a supported compatibility alias until retained jobs can no longer run."],
50
+ b.declaration_locations + contract_proof(b) + producer_proof(hc))
46
51
  else
47
- findings << presence_unknown(name, head_presence, b.declaration_locations + head.index.locations(name), "base_to_head", "head")
52
+ findings << presence_unknown(name, head_presence, b.declaration_locations + head.index.locations(name), "base_to_head", "head") unless head.aliases.key?(name)
48
53
  end
49
54
  elsif h && !b && !hc.empty?
50
55
  if base_presence == "absent"
51
56
  findings << finding("JC005", name, ["base", "head"], ["head_to_base"], nil, nil,
52
- "HEAD can enqueue a worker class absent from base tracked Ruby source.",
57
+ "HEAD can enqueue a serialized worker identity absent from base tracked Ruby source.",
53
58
  "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] })
59
+ ["Deploy the worker class or compatibility alias before activating its producer."], h.declaration_locations + producer_proof(hc))
55
60
  else
56
- findings << presence_unknown(name, base_presence, h.declaration_locations + base.index.locations(name), "head_to_base", "base")
61
+ findings << presence_unknown(name, base_presence, h.declaration_locations + base.index.locations(name), "head_to_base", "base") unless base.aliases.key?(name)
57
62
  end
58
63
  end
59
64
 
@@ -66,7 +71,7 @@ module Jobcompat
66
71
  head_same = head_groups.fetch(arity, []).select { |call| !h.accepts?(call[:arity]) }
67
72
  directions = ["base_to_head"]
68
73
  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] }
74
+ locations = producer_proof(group) + contract_proof(b) + contract_proof(h) + producer_proof(head_same)
70
75
  findings << finding("JC001", name, head_same.empty? ? ["base", "head"] : ["base", "head"], directions, arity, nil,
71
76
  "Base emits #{arity} #{argument_word(arity)}, but the HEAD worker accepts #{h.display_range}.",
72
77
  "Jobs queued by the base revision may fail after deployment.",
@@ -82,14 +87,14 @@ module Jobcompat
82
87
  findings << finding("JC003", name, ["head"], ["head_to_head"], arity, nil,
83
88
  "HEAD emits #{arity} #{argument_word(arity)}, but its worker accepts #{h.display_range}.",
84
89
  "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" })
90
+ producer_proof(group) + contract_proof(h))
86
91
  current_removed_witness ||= b&.known? && b.accepts?(arity)
87
92
  elsif b&.known? && !b.accepts?(arity)
88
93
  findings << finding("JC002", name, ["base", "head"], ["head_to_base"], arity, nil,
89
94
  "HEAD emits #{arity} #{argument_word(arity)}, but the base worker accepts #{b.display_range}.",
90
95
  "A new producer can enqueue work that an old worker cannot execute during a rolling deploy.",
91
96
  ["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" })
97
+ producer_proof(group) + contract_proof(b) + contract_proof(h))
93
98
  end
94
99
  end
95
100
  end
@@ -101,12 +106,13 @@ module Jobcompat
101
106
  "The HEAD worker removed #{label} #{removed.join(', ')} from the base contract; no qualifying producer witness was found.",
102
107
  "No repository producer callsite found does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists.",
103
108
  ["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" })
109
+ contract_proof(b) + contract_proof(h))
105
110
  end
106
111
  end
107
112
 
108
113
  {
109
114
  name: name, base_presence: base_presence, head_presence: head_presence,
115
+ base_alias: base.aliases[name]&.as_json, head_alias: head.aliases[name]&.as_json,
110
116
  base_contract: b&.as_json, head_contract: h&.as_json,
111
117
  producer_arities: {base: bc.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort,
112
118
  head: hc.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort,
@@ -122,35 +128,72 @@ module Jobcompat
122
128
 
123
129
  private
124
130
 
125
- def resolve(raw_calls)
126
- raw_calls.sort_by { |call| [call.path, call.location.line, call.location.column, call.method,
131
+ def resolve(snapshot)
132
+ snapshot.calls.sort_by { |call| [call.path, call.location.line, call.location.column, call.method,
127
133
  call.arity.nil? ? Float::INFINITY : call.arity, call.unknown_reason.to_s] }.filter_map do |call|
128
134
  worker = nil
135
+ attributed_worker = nil
136
+ binding = nil
137
+ binding_locations = []
138
+ identity_reason = nil
129
139
  if call.receiver
130
140
  candidates = if call.resolution_mode == "exact_string" || call.root
131
141
  [call.receiver]
132
- elsif call.namespace.nil?
133
- []
134
142
  else
135
- (call.namespace.reverse.map { |prefix| "#{prefix}::#{call.receiver}" } + [call.receiver]).uniq
143
+ ConstantLookup.candidates(call.receiver, false, call.namespace)
136
144
  end
137
- worker = candidates.find { |name| @names.include?(name) }
145
+ worker = producer_identity(snapshot, candidates, call.resolution_mode)
146
+ attributed_worker = worker
147
+ if worker && call.resolution_mode != "exact_string" && snapshot.aliases.key?(worker)
148
+ binding = snapshot.aliases[worker]
149
+ worker = binding.resolved? ? binding.target : nil if binding
150
+ binding_locations = snapshot.workers.fetch(binding.target).locations if binding.resolved?
151
+ elsif worker && alias_lookup?(candidates, call.resolution_mode) && snapshot.bindings.key?(worker) && !snapshot.workers.key?(worker)
152
+ binding_locations = snapshot.bindings.fetch(worker).map(&:location)
153
+ identity_reason = "worker_not_recognized"
154
+ worker = nil
155
+ elsif worker && call.resolution_mode != "exact_string" && !snapshot.workers.key?(worker) && (base.aliases.key?(worker) || head.aliases.key?(worker))
156
+ worker = nil
157
+ end
138
158
  client_target = %w[client_constant exact_string].include?(call.resolution_mode)
139
- next unless worker || client_target || (!call.root && call.namespace.nil?)
159
+ next unless worker || attributed_worker || client_target || (!call.root && call.namespace.nil?)
140
160
  end
141
- reason = call.unknown_reason
161
+ identity_reason ||= binding && !binding.resolved? ? binding.unknown_reason : nil
162
+ identity_reason ||= "alias_target_unresolved" if worker.nil? && attributed_worker
163
+ reason = identity_reason || call.unknown_reason
142
164
  reason ||= "dynamic_client_class" if call.receiver && worker.nil? && %w[client_constant exact_string].include?(call.resolution_mode)
143
165
  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}
166
+ {worker: worker, attributed_worker: attributed_worker, identity_known: !worker.nil?, identity_reason: identity_reason,
167
+ location: call.location, locations: [call.location] + (binding&.locations || []) + binding_locations,
168
+ arity: call.arity, reason: reason, raw: call}
169
+ end
170
+ end
171
+
172
+ def producer_identity(snapshot, candidates, mode)
173
+ if alias_lookup?(candidates, mode)
174
+ local = candidates.find { |name| snapshot.bindings.key?(name) || snapshot.workers.key?(name) || snapshot.aliases.key?(name) }
175
+ return local if local
145
176
  end
177
+ candidates.find { |name| @lookup_names.include?(name) }
178
+ end
179
+
180
+ def alias_lookup?(candidates, mode)
181
+ mode != "exact_string" && candidates.any? { |name| base.aliases.key?(name) || head.aliases.key?(name) }
146
182
  end
147
183
 
148
184
  def presence(snapshot, name, contract, queried)
149
- return "recognized_worker" if contract
185
+ return snapshot.presence(name) if contract || snapshot.aliases.key?(name)
150
186
  return "not_checked" unless queried
151
187
  snapshot.index.status(name)
152
188
  end
153
189
 
190
+ def producer_proof(group) = group.flat_map { |call| call[:locations] }
191
+
192
+ def contract_proof(contract)
193
+ return contract.locations if base.aliases.key?(contract.name) || head.aliases.key?(contract.name)
194
+ contract.locations.select { |location| location.role == "consumer" }
195
+ end
196
+
154
197
  def cell(producers, consumer, status)
155
198
  return "not_applicable" if producers.empty?
156
199
  known = producers.filter_map { |call| call[:arity] unless call[:reason] }
@@ -196,22 +239,32 @@ module Jobcompat
196
239
  def unknown_findings
197
240
  roots = []
198
241
  [base, head].each do |snapshot|
199
- snapshot.workers.each do |name, contract|
200
- next if contract.known?
242
+ @names.each do |name|
243
+ contract = snapshot.consumer(name)
244
+ next if !contract || contract.known?
201
245
  parts = ["consumer_contract", contract.unknown_reason, name, contract.fingerprint_parts]
202
246
  roots << {revision: snapshot.label, kind: "consumer_contract", reason: contract.unknown_reason, worker: name,
203
247
  locations: contract.locations, parts: parts, offset: contract.locations.first&.line || 0}
204
248
  end
249
+ snapshot.aliases.each do |name, binding|
250
+ next if binding.resolved? || !@names.include?(name)
251
+ producer_locations = calls[snapshot.label].select { |call| call[:identity_reason] && call[:attributed_worker] == name }.flat_map { |call| call[:locations] }
252
+ roots << {revision: snapshot.label, kind: "consumer_alias", reason: binding.unknown_reason, worker: name,
253
+ locations: binding.locations + producer_locations,
254
+ parts: ["consumer_alias", binding.unknown_reason, name, binding.fingerprint_parts, binding.chain], offset: 0}
255
+ end
205
256
  snapshot.unknowns.each do |item|
206
257
  roots << {revision: snapshot.label, kind: item.kind, reason: item.reason, worker: item.worker,
207
258
  locations: item.locations, parts: [item.kind, item.reason, item.worker, item.fingerprint_parts], offset: item.locations.first&.line || 0}
208
259
  end
209
260
  calls[snapshot.label].each do |call|
210
261
  next unless call[:reason]
262
+ next if call[:identity_reason] && snapshot.aliases.key?(call[:attributed_worker])
211
263
  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],
264
+ kind = call[:identity_reason] || %w[dynamic_receiver unsupported_constant_path safe_navigation_receiver].include?(call[:reason]) ? "producer_receiver" : "producer_arity"
265
+ diagnostic_worker = call[:worker] || (call[:identity_reason] && call[:attributed_worker])
266
+ roots << {revision: snapshot.label, kind: kind, reason: call[:reason], worker: diagnostic_worker,
267
+ locations: call[:locations], parts: [kind, call[:reason], diagnostic_worker, raw.fingerprint_parts],
215
268
  offset: [call[:location].line, call[:location].column]}
216
269
  end
217
270
  end
@@ -227,20 +280,35 @@ module Jobcompat
227
280
  when ["producer_arity", "base"], ["producer_receiver", "base"] then ["base_to_head"]
228
281
  when ["producer_arity", "head"], ["producer_receiver", "head"] then ["head_to_base", "head_to_head"]
229
282
  when ["consumer_contract", "base"], ["worker_identity", "base"] then ["head_to_base"]
283
+ when ["consumer_alias", "base"] then ["head_to_base"]
284
+ when ["consumer_alias", "head"]
285
+ @calls_by_worker["head"].key?(root[:worker]) ? ["base_to_head", "head_to_head"] : ["base_to_head"]
230
286
  else ["base_to_head", "head_to_head"]
231
287
  end
232
288
  end.uniq
233
289
  if first[:kind] == "producer_arity" && first[:worker]
234
- head_only_new = head.workers.key?(first[:worker]) && !base.workers.key?(first[:worker])
290
+ head_only_new = head.consumer(first[:worker]) && !base.consumer(first[:worker])
235
291
  directions.delete("head_to_base") if head_only_new && group.any? { |root| root[:revision] == "head" } && base.index.status(first[:worker]) == "absent"
236
292
  end
293
+ alias_reason = %w[alias_target_unresolved alias_cycle alias_binding_conflict unsupported_alias_assignment].include?(first[:reason])
294
+ message = alias_reason ? alias_message(first[:worker], first[:reason]) : "#{first[:reason]} prevents a complete positional compatibility check."
295
+ risk = alias_reason ? "Compatibility for jobs serialized as #{first[:worker]} cannot be proven from selected source." : "The affected producer or consumer contract is unknown."
296
+ 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."]
237
297
  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."],
298
+ message, risk, remediation,
240
299
  group.flat_map { |root| root[:locations] })
241
300
  end
242
301
  end
243
302
 
303
+ def alias_message(name, reason)
304
+ case reason
305
+ when "alias_target_unresolved" then "#{name} is bound as a compatibility alias, but its target cannot be resolved to a supported Sidekiq worker."
306
+ when "alias_cycle" then "Compatibility alias resolution for #{name} enters a constant-alias cycle."
307
+ when "alias_binding_conflict" then "#{name} has multiple or conflicting constant bindings, so its runtime job class cannot be proven."
308
+ when "unsupported_alias_assignment" then "#{name} is assigned through syntax or control flow that does not prove one unconditional static compatibility alias."
309
+ end
310
+ end
311
+
244
312
  def finding_sort_key(item)
245
313
  loc = item[:locations].first
246
314
  [item[:severity] == "error" ? 0 : 1, item[:rule_id], item[:worker] || "\u{10ffff}", item[:payload_arity] || Float::INFINITY,
@@ -1,3 +1,3 @@
1
1
  module Jobcompat
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/jobcompat.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require_relative "jobcompat/version"
2
2
  require_relative "jobcompat/errors"
3
+ require_relative "jobcompat/canonical_worker_name"
3
4
  require_relative "jobcompat/config"
4
5
  require_relative "jobcompat/git_repository"
5
6
  require_relative "jobcompat/analysis"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: jobcompat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - jobcompat contributors
@@ -42,17 +42,27 @@ files:
42
42
  - SECURITY.md
43
43
  - docs/architecture.md
44
44
  - docs/competitive-analysis.md
45
+ - docs/implementation-plan-v0.2.1.md
45
46
  - docs/implementation-plan-v0.2.md
47
+ - docs/implementation-plan-v0.3.md
46
48
  - docs/implementation-plan.md
47
49
  - docs/release-checklist-v0.1.0.md
48
50
  - docs/release-checklist-v0.2.0.md
51
+ - docs/release-checklist-v0.2.1.md
52
+ - docs/release-checklist-v0.3.0.md
49
53
  - docs/release-notes-v0.1.0.md
50
54
  - docs/release-notes-v0.2.0.md
55
+ - docs/release-notes-v0.2.1.md
56
+ - docs/release-notes-v0.3.0.md
51
57
  - docs/spec-v0.1.md
58
+ - docs/spec-v0.2.1.md
52
59
  - docs/spec-v0.2.md
60
+ - docs/spec-v0.3.md
53
61
  - exe/jobcompat
54
62
  - lib/jobcompat.rb
63
+ - lib/jobcompat/aliases.rb
55
64
  - lib/jobcompat/analysis.rb
65
+ - lib/jobcompat/canonical_worker_name.rb
56
66
  - lib/jobcompat/cli.rb
57
67
  - lib/jobcompat/config.rb
58
68
  - lib/jobcompat/engine.rb