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.
@@ -0,0 +1,144 @@
1
+ module Jobcompat
2
+ Explanation = Data.define(:evidence, :decision, :boundary)
3
+
4
+ module ExplanationBuilder
5
+ REVISION_LABELS = {"base" => "BASE", "head" => "HEAD"}.freeze
6
+
7
+ module_function
8
+
9
+ def jc001(arity:, base_contract:, head_contract:, head_witness:, aliases:)
10
+ evidence = [
11
+ "BASE producer emits #{arity} positional #{argument_word(arity)}.",
12
+ "BASE consumer accepts #{arity} positional #{argument_word(arity)} (#{range(base_contract)}).",
13
+ "HEAD consumer accepts #{range(head_contract)} positional #{range_word(head_contract)}, rejecting #{arity}."
14
+ ]
15
+ evidence << "HEAD producer also emits #{arity} positional #{argument_word(arity)}, enqueuing the same payload shape." if head_witness
16
+ decision = "A payload accepted in BASE (#{arity} positional #{argument_word(arity)}) is rejected by the HEAD worker (#{range(head_contract)})."
17
+ decision << " HEAD producers still enqueue the same arity." if head_witness
18
+ build(evidence + aliases, decision,
19
+ "Static arity evidence; actual retained queue contents are not inspected.")
20
+ end
21
+
22
+ def jc002(arity:, base_contract:, head_contract:, aliases:)
23
+ evidence = [
24
+ "HEAD producer emits #{arity} positional #{argument_word(arity)}.",
25
+ "HEAD consumer accepts #{range(head_contract)} positional #{range_word(head_contract)}.",
26
+ "BASE consumer accepts #{range(base_contract)} positional #{range_word(base_contract)}, rejecting #{arity}."
27
+ ]
28
+ build(evidence + aliases,
29
+ "A payload enqueued by HEAD (#{arity} positional #{argument_word(arity)}) would be rejected by the base worker if an old process executes it during the rolling deployment.",
30
+ "Whether an old process consumes this queue, and when it retires, is not determined by static analysis.")
31
+ end
32
+
33
+ def jc003(arity:, head_contract:, aliases:)
34
+ evidence = [
35
+ "HEAD producer emits #{arity} positional #{argument_word(arity)}.",
36
+ "HEAD consumer accepts #{range(head_contract)} positional #{range_word(head_contract)}, rejecting #{arity}."
37
+ ]
38
+ build(evidence + aliases,
39
+ "HEAD's own producer and consumer disagree on payload arity.",
40
+ "Static arity evidence within HEAD; base-revision evidence does not affect this finding.")
41
+ end
42
+
43
+ def jc004(worker:, base_contract:, head_producers:, aliases:)
44
+ evidence = [consumer_fact("base", worker, base_contract),
45
+ "HEAD conservative presence check across tracked Ruby source proved #{worker} absent."]
46
+ evidence << "HEAD still contains #{call_sites(distinct_call_sites(head_producers))} referencing this identity." if head_producers.any?
47
+ build(evidence + aliases,
48
+ "A retained job serialized as #{worker} cannot be resolved to a worker by HEAD tracked Ruby source.",
49
+ "Static absence does not prove the queue is empty; queued, retried, scheduled, historical, or externally enqueued jobs may still reference this identity.")
50
+ end
51
+
52
+ def jc005(worker:, head_contract:, head_calls:, aliases:)
53
+ evidence = ["HEAD producer can enqueue the serialized worker identity #{worker} (#{call_sites(distinct_call_sites(head_calls))})."]
54
+ head_calls.map { |call| call[:attributed_worker] }.compact.uniq.sort.reject { |name| name == worker }.each do |name|
55
+ evidence << "HEAD Class-object producer #{name} serializes as the terminal identity #{worker}."
56
+ end
57
+ evidence << "HEAD Client String producer keeps the exact identity #{worker}." if head_calls.any? { |call| call[:raw].resolution_mode == "exact_string" }
58
+ known = head_calls.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort
59
+ evidence << "Known HEAD payload arities for this identity: #{known.join(', ')}." if known.any?
60
+ evidence << consumer_fact("head", worker, head_contract)
61
+ evidence << "BASE conservative presence check across tracked Ruby source proved #{worker} absent."
62
+ build(evidence + aliases,
63
+ "If an old Sidekiq process consumes this job's queue during the rolling deployment, it cannot resolve the serialized identity #{worker}.",
64
+ "This does not prove unconditional failure; the risk is conditional on an old process consuming the relevant queue before retirement, which static analysis cannot determine.")
65
+ end
66
+
67
+ def jc006(worker:, base_contract:, head_contract:, removed:, aliases:)
68
+ evidence = [
69
+ consumer_fact("base", worker, base_contract),
70
+ consumer_fact("head", worker, head_contract),
71
+ "HEAD no longer accepts #{removed.length == 1 && removed.first.match?(/\A\d+\z/) ? "arity" : "arities"} #{removed.join(', ')}.",
72
+ "No qualifying producer witness was found for the removed range in either revision."
73
+ ]
74
+ build(evidence + aliases,
75
+ "The worker contract narrowed from #{range(base_contract)} to #{range(head_contract)} without a qualifying producer witness.",
76
+ "No repository producer witness does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists.")
77
+ end
78
+
79
+ def presence_unknown(worker:, status:, revision:)
80
+ label = REVISION_LABELS.fetch(revision)
81
+ fact = case status
82
+ when "defined_unrecognized" then "#{worker} is defined in #{label} selected source, but is not recognized as a Sidekiq worker."
83
+ when "outside_scan_scope" then "#{worker} is declared only outside the scan scope in #{label}."
84
+ when "unverified" then "Presence of #{worker} in #{label} could not be verified within the analysis budget."
85
+ end
86
+ build([fact],
87
+ "The Sidekiq contract for #{worker} is not recognized, so this transition cannot be proven.",
88
+ "Unknown is not a proof of compatibility; the affected evidence was excluded from the compatibility decision.")
89
+ end
90
+
91
+ def unknown_group(roots:, message:)
92
+ build(roots.map { |root| root_fact(root) }, message,
93
+ "Unknown or unsupported evidence is not a proof of compatibility; the affected calls or contracts were excluded from the compatibility decision.")
94
+ end
95
+
96
+ def root_fact(root)
97
+ label = REVISION_LABELS.fetch(root[:revision])
98
+ case root[:kind]
99
+ when "consumer_contract" then "#{label}: the consumer contract for #{root[:worker]} is unknown (#{root[:reason]})."
100
+ when "consumer_alias"
101
+ chain = root[:chain]
102
+ path = chain && chain.length > 1 ? chain.join(" -> ") : "unresolved"
103
+ "Alias (#{label}): #{path} (#{root[:reason]})."
104
+ when "worker_identity" then "#{label}: a Sidekiq-including class is declared through an unsupported constant path."
105
+ when "producer_receiver"
106
+ root[:worker] ? "#{label}: the producer receiver for #{root[:worker]} could not be resolved (#{root[:reason]})." :
107
+ "#{label}: a producer receiver could not be resolved (#{root[:reason]})."
108
+ when "producer_arity"
109
+ root[:worker] ? "#{label}: the payload arity for #{root[:worker]} could not be determined (#{root[:reason]})." :
110
+ "#{label}: a producer payload arity could not be determined (#{root[:reason]})."
111
+ end
112
+ end
113
+
114
+ def consumer_fact(revision, worker, contract)
115
+ contract.known? ?
116
+ "#{REVISION_LABELS.fetch(revision)} consumer #{worker} accepts #{range(contract)} positional #{range_word(contract)}." :
117
+ "#{REVISION_LABELS.fetch(revision)} consumer #{worker} has an unknown contract (#{contract.unknown_reason})."
118
+ end
119
+
120
+ def range_word(contract)
121
+ contract.display_range.match?(/\A\d+\z/) ? argument_word(contract.display_range.to_i) : "arguments"
122
+ end
123
+
124
+ def range(contract)
125
+ contract.display_range
126
+ end
127
+
128
+ def argument_word(number)
129
+ number == 1 ? "argument" : "arguments"
130
+ end
131
+
132
+ def call_sites(number)
133
+ "#{number} call site#{number == 1 ? '' : 's'}"
134
+ end
135
+
136
+ def distinct_call_sites(calls)
137
+ calls.map { |call| call[:location] }.uniq { |location| [location.revision, location.path, location.line, location.column] }.length
138
+ end
139
+
140
+ def build(evidence, decision, boundary)
141
+ Explanation.new(evidence.freeze, decision, boundary).freeze
142
+ end
143
+ end
144
+ end
@@ -15,7 +15,7 @@ module Jobcompat
15
15
  end
16
16
  end
17
17
 
18
- def self.text(envelope)
18
+ def self.text(envelope, explanations: nil)
19
19
  return envelope[:diagnostics].map { |item| safe_line("#{item[:category]}: #{item[:message]}") }.join("\n") + "\n" if envelope[:status] == "failed"
20
20
  base = envelope[:comparison][:base]
21
21
  head = envelope[:comparison][:head]
@@ -35,6 +35,16 @@ module Jobcompat
35
35
  finding[:locations].each do |location|
36
36
  lines << " #{location.revision} #{location.path}:#{location.line}:#{location.column} (#{location.role})"
37
37
  end
38
+ explanation = explanations && explanations[finding]
39
+ if explanation
40
+ lines << " Explanation:"
41
+ lines << " Evidence:"
42
+ explanation.evidence.each { |fact| lines << " #{fact}" }
43
+ lines << " Decision:"
44
+ lines << " #{explanation.decision}"
45
+ lines << " Boundary:"
46
+ lines << " #{explanation.boundary}"
47
+ end
38
48
  lines << " Suggested migration:"
39
49
  finding[:remediation].each_with_index { |step, index| lines << " #{index + 1}. #{step}" }
40
50
  lines << ""
@@ -1,3 +1,3 @@
1
1
  module Jobcompat
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.1"
3
3
  end
data/lib/jobcompat.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "jobcompat/canonical_worker_name"
4
4
  require_relative "jobcompat/config"
5
5
  require_relative "jobcompat/git_repository"
6
6
  require_relative "jobcompat/analysis"
7
+ require_relative "jobcompat/explanation"
7
8
  require_relative "jobcompat/engine"
8
9
  require_relative "jobcompat/formatter"
9
10
  require_relative "jobcompat/cli"
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.1
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - jobcompat contributors
@@ -44,24 +44,32 @@ files:
44
44
  - docs/competitive-analysis.md
45
45
  - docs/implementation-plan-v0.2.1.md
46
46
  - docs/implementation-plan-v0.2.md
47
+ - docs/implementation-plan-v0.3.1.md
48
+ - docs/implementation-plan-v0.3.md
47
49
  - docs/implementation-plan.md
48
50
  - docs/release-checklist-v0.1.0.md
49
51
  - docs/release-checklist-v0.2.0.md
50
52
  - docs/release-checklist-v0.2.1.md
53
+ - docs/release-checklist-v0.3.0.md
51
54
  - docs/release-notes-v0.1.0.md
52
55
  - docs/release-notes-v0.2.0.md
53
56
  - docs/release-notes-v0.2.1.md
57
+ - docs/release-notes-v0.3.0.md
54
58
  - docs/spec-v0.1.md
55
59
  - docs/spec-v0.2.1.md
56
60
  - docs/spec-v0.2.md
61
+ - docs/spec-v0.3.1.md
62
+ - docs/spec-v0.3.md
57
63
  - exe/jobcompat
58
64
  - lib/jobcompat.rb
65
+ - lib/jobcompat/aliases.rb
59
66
  - lib/jobcompat/analysis.rb
60
67
  - lib/jobcompat/canonical_worker_name.rb
61
68
  - lib/jobcompat/cli.rb
62
69
  - lib/jobcompat/config.rb
63
70
  - lib/jobcompat/engine.rb
64
71
  - lib/jobcompat/errors.rb
72
+ - lib/jobcompat/explanation.rb
65
73
  - lib/jobcompat/formatter.rb
66
74
  - lib/jobcompat/git_repository.rb
67
75
  - lib/jobcompat/version.rb