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
|
@@ -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
|
data/lib/jobcompat/formatter.rb
CHANGED
|
@@ -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 << ""
|
data/lib/jobcompat/version.rb
CHANGED
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.
|
|
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
|