jobcompat 0.1.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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +161 -0
- data/SECURITY.md +5 -0
- data/docs/architecture.md +587 -0
- data/docs/competitive-analysis.md +268 -0
- data/docs/implementation-plan.md +806 -0
- data/docs/release-checklist-v0.1.0.md +49 -0
- data/docs/release-notes-v0.1.0.md +24 -0
- data/docs/spec-v0.1.md +1157 -0
- data/exe/jobcompat +3 -0
- data/lib/jobcompat/analysis.rb +340 -0
- data/lib/jobcompat/cli.rb +107 -0
- data/lib/jobcompat/config.rb +89 -0
- data/lib/jobcompat/engine.rb +247 -0
- data/lib/jobcompat/errors.rb +12 -0
- data/lib/jobcompat/formatter.rb +64 -0
- data/lib/jobcompat/git_repository.rb +78 -0
- data/lib/jobcompat/version.rb +3 -0
- data/lib/jobcompat.rb +8 -0
- metadata +84 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
module Jobcompat
|
|
2
|
+
class Engine
|
|
3
|
+
DIRECTIONS = %w[base_to_head head_to_base head_to_head].freeze
|
|
4
|
+
REVISIONS = %w[base head].freeze
|
|
5
|
+
TITLES = {
|
|
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",
|
|
8
|
+
"JC005" => "New worker enqueued before old fleet can understand it", "JC006" => "Worker contract narrowed without sufficient producer evidence",
|
|
9
|
+
"JC007" => "Compatibility could not be proven"
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
attr_reader :base, :head, :calls
|
|
13
|
+
|
|
14
|
+
def initialize(base, head)
|
|
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)}
|
|
18
|
+
@calls_by_worker = {"base" => calls["base"].group_by { |call| call[:worker] },
|
|
19
|
+
"head" => calls["head"].group_by { |call| call[:worker] }}
|
|
20
|
+
@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
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def presence_requests
|
|
27
|
+
@presence_requests
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def evaluate
|
|
31
|
+
findings = []
|
|
32
|
+
workers = @names.map do |name|
|
|
33
|
+
b = base.workers[name]
|
|
34
|
+
h = head.workers[name]
|
|
35
|
+
bc = @calls_by_worker["base"].fetch(name, [])
|
|
36
|
+
hc = @calls_by_worker["head"].fetch(name, [])
|
|
37
|
+
base_presence = presence(base, name, b, presence_requests["base"].include?(name))
|
|
38
|
+
head_presence = presence(head, name, h, presence_requests["head"].include?(name))
|
|
39
|
+
if b && !h
|
|
40
|
+
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")
|
|
48
|
+
end
|
|
49
|
+
elsif h && !b && !hc.empty?
|
|
50
|
+
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")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
base_groups = bc.select { |call| !call[:reason] && !call[:arity].nil? }.group_by { |call| call[:arity] }
|
|
61
|
+
head_groups = hc.select { |call| !call[:reason] && !call[:arity].nil? }.group_by { |call| call[:arity] }
|
|
62
|
+
proven_narrow = false
|
|
63
|
+
if b&.known? && h&.known?
|
|
64
|
+
base_groups.sort.each do |arity, group|
|
|
65
|
+
next unless b.accepts?(arity) && !h.accepts?(arity)
|
|
66
|
+
head_same = head_groups.fetch(arity, []).select { |call| !h.accepts?(call[:arity]) }
|
|
67
|
+
directions = ["base_to_head"]
|
|
68
|
+
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)
|
|
74
|
+
proven_narrow = true
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
current_removed_witness = false
|
|
78
|
+
if h&.known?
|
|
79
|
+
head_groups.sort.each do |arity, group|
|
|
80
|
+
if !h.accepts?(arity)
|
|
81
|
+
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" })
|
|
86
|
+
current_removed_witness ||= b&.known? && b.accepts?(arity)
|
|
87
|
+
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" })
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
if b&.known? && h&.known?
|
|
97
|
+
unless h.superset_of?(b) || proven_narrow || current_removed_witness
|
|
98
|
+
removed = removed_arity_ranges(b, h)
|
|
99
|
+
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" })
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
name: name, base_presence: base_presence, head_presence: head_presence,
|
|
110
|
+
base_contract: b&.as_json, head_contract: h&.as_json,
|
|
111
|
+
producer_arities: {base: bc.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort,
|
|
112
|
+
head: hc.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort,
|
|
113
|
+
base_unknown_calls: bc.count { |call| call[:reason] }, head_unknown_calls: hc.count { |call| call[:reason] }},
|
|
114
|
+
compatibility: {base_to_base: cell(bc, b, base_presence), base_to_head: cell(bc, h, head_presence),
|
|
115
|
+
head_to_base: cell(hc, b, base_presence), head_to_head: cell(hc, h, head_presence)}
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
findings.concat(unknown_findings)
|
|
119
|
+
findings = findings.sort_by { |item| finding_sort_key(item) }
|
|
120
|
+
{findings: findings, workers: workers}
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def resolve(raw_calls)
|
|
126
|
+
raw_calls.sort_by { |call| [call.path, call.location.line, call.location.column] }.filter_map do |call|
|
|
127
|
+
worker = nil
|
|
128
|
+
if call.receiver
|
|
129
|
+
candidates = if call.root
|
|
130
|
+
[call.receiver]
|
|
131
|
+
elsif call.namespace.nil?
|
|
132
|
+
[]
|
|
133
|
+
else
|
|
134
|
+
(call.namespace.reverse.map { |prefix| "#{prefix}::#{call.receiver}" } + [call.receiver]).uniq
|
|
135
|
+
end
|
|
136
|
+
worker = candidates.find { |name| @names.include?(name) }
|
|
137
|
+
next unless worker || (!call.root && call.namespace.nil?)
|
|
138
|
+
end
|
|
139
|
+
reason = call.unknown_reason
|
|
140
|
+
reason ||= call.namespace.nil? ? "unsupported_constant_path" : "dynamic_receiver" if worker.nil?
|
|
141
|
+
{worker: worker, location: call.location, arity: call.arity, reason: reason, raw: call}
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def presence(snapshot, name, contract, queried)
|
|
146
|
+
return "recognized_worker" if contract
|
|
147
|
+
return "not_checked" unless queried
|
|
148
|
+
snapshot.index.status(name)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def cell(producers, consumer, status)
|
|
152
|
+
return "not_applicable" if producers.empty?
|
|
153
|
+
known = producers.filter_map { |call| call[:arity] unless call[:reason] }
|
|
154
|
+
return "fail" if consumer&.known? && known.any? { |arity| !consumer.accepts?(arity) }
|
|
155
|
+
return "unknown" if !consumer&.known? && status != "absent"
|
|
156
|
+
return "unknown" if producers.any? { |call| call[:reason] }
|
|
157
|
+
return "not_applicable" if status == "absent"
|
|
158
|
+
"pass"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def argument_word(number) = number == 1 ? "argument" : "arguments"
|
|
162
|
+
|
|
163
|
+
def removed_arity_ranges(base_contract, head_contract)
|
|
164
|
+
ranges = []
|
|
165
|
+
if head_contract.min_arity > base_contract.min_arity
|
|
166
|
+
last = [head_contract.min_arity - 1, base_contract.max_arity].compact.min
|
|
167
|
+
ranges << [base_contract.min_arity, last]
|
|
168
|
+
end
|
|
169
|
+
if head_contract.max_arity && (base_contract.max_arity.nil? || head_contract.max_arity < base_contract.max_arity)
|
|
170
|
+
first = [base_contract.min_arity, head_contract.max_arity + 1].max
|
|
171
|
+
ranges << [first, base_contract.max_arity]
|
|
172
|
+
end
|
|
173
|
+
ranges.map do |first, last|
|
|
174
|
+
last.nil? ? "#{first}..∞" : (first == last ? first.to_s : "#{first}..#{last}")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def finding(rule, worker, revisions, directions, arity, reason, message, risk, remediation, locations)
|
|
179
|
+
sorted_locations = locations.compact.uniq.sort_by { |loc| [REVISIONS.index(loc.revision), loc.path, loc.line, loc.column, loc.role] }
|
|
180
|
+
{rule_id: rule, title: TITLES.fetch(rule), severity: rule <= "JC005" ? "error" : "warning", worker: worker,
|
|
181
|
+
revisions: revisions.sort_by { |revision| REVISIONS.index(revision) }, directions: directions.sort_by { |direction| DIRECTIONS.index(direction) },
|
|
182
|
+
unknown_reason: reason, message: message, risk: risk, remediation: remediation, payload_arity: arity, locations: sorted_locations}
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def presence_unknown(name, status, locations, direction, revision)
|
|
186
|
+
reason = {"defined_unrecognized" => "worker_not_recognized", "outside_scan_scope" => "outside_analysis_scope", "unverified" => "presence_unverified"}.fetch(status)
|
|
187
|
+
finding("JC007", name, [revision], [direction], nil, reason,
|
|
188
|
+
"#{name} is present or cannot be proven absent, but its Sidekiq contract is not recognized.",
|
|
189
|
+
"Compatibility for this class transition cannot be proven from selected source.",
|
|
190
|
+
["Restore a directly recognized worker declaration, or inspect the deployment manually."], locations)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def unknown_findings
|
|
194
|
+
roots = []
|
|
195
|
+
[base, head].each do |snapshot|
|
|
196
|
+
snapshot.workers.each do |name, contract|
|
|
197
|
+
next if contract.known?
|
|
198
|
+
parts = ["consumer_contract", contract.unknown_reason, name, contract.fingerprint_parts]
|
|
199
|
+
roots << {revision: snapshot.label, kind: "consumer_contract", reason: contract.unknown_reason, worker: name,
|
|
200
|
+
locations: contract.locations, parts: parts, offset: contract.locations.first&.line || 0}
|
|
201
|
+
end
|
|
202
|
+
snapshot.unknowns.each do |item|
|
|
203
|
+
roots << {revision: snapshot.label, kind: item.kind, reason: item.reason, worker: item.worker,
|
|
204
|
+
locations: item.locations, parts: [item.kind, item.reason, item.worker, item.fingerprint_parts], offset: item.locations.first&.line || 0}
|
|
205
|
+
end
|
|
206
|
+
calls[snapshot.label].each do |call|
|
|
207
|
+
next unless call[:reason]
|
|
208
|
+
raw = call[:raw]
|
|
209
|
+
kind = %w[dynamic_receiver unsupported_constant_path safe_navigation_receiver].include?(call[:reason]) ? "producer_receiver" : "producer_arity"
|
|
210
|
+
roots << {revision: snapshot.label, kind: kind, reason: call[:reason], worker: call[:worker],
|
|
211
|
+
locations: [call[:location]], parts: [kind, call[:reason], call[:worker], raw.fingerprint_parts],
|
|
212
|
+
offset: [call[:location].line, call[:location].column]}
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
groups = roots.group_by { |root| [root[:revision], root[:parts]] }
|
|
216
|
+
groups.each_value do |group|
|
|
217
|
+
ordered = group.sort_by { |item| item[:offset] }
|
|
218
|
+
ordered.each_with_index { |root, index| root[:fingerprint] = [root[:parts], ordered.length, index + 1] }
|
|
219
|
+
end
|
|
220
|
+
roots.group_by { |root| root[:fingerprint] }.values.map do |group|
|
|
221
|
+
first = group.first
|
|
222
|
+
directions = group.flat_map do |root|
|
|
223
|
+
case [root[:kind], root[:revision]]
|
|
224
|
+
when ["producer_arity", "base"], ["producer_receiver", "base"] then ["base_to_head"]
|
|
225
|
+
when ["producer_arity", "head"], ["producer_receiver", "head"] then ["head_to_base", "head_to_head"]
|
|
226
|
+
when ["consumer_contract", "base"], ["worker_identity", "base"] then ["head_to_base"]
|
|
227
|
+
else ["base_to_head", "head_to_head"]
|
|
228
|
+
end
|
|
229
|
+
end.uniq
|
|
230
|
+
if first[:kind] == "producer_arity" && first[:worker]
|
|
231
|
+
head_only_new = head.workers.key?(first[:worker]) && !base.workers.key?(first[:worker])
|
|
232
|
+
directions.delete("head_to_base") if head_only_new && group.any? { |root| root[:revision] == "head" } && base.index.status(first[:worker]) == "absent"
|
|
233
|
+
end
|
|
234
|
+
finding("JC007", first[:worker], group.map { |root| root[:revision] }.uniq, directions, nil, first[:reason],
|
|
235
|
+
"#{first[:reason]} prevents a complete positional compatibility check.",
|
|
236
|
+
"The affected producer or consumer contract is unknown.", ["Use supported explicit syntax or inspect this call manually."],
|
|
237
|
+
group.flat_map { |root| root[:locations] })
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def finding_sort_key(item)
|
|
242
|
+
loc = item[:locations].first
|
|
243
|
+
[item[:severity] == "error" ? 0 : 1, item[:rule_id], item[:worker] || "\u{10ffff}", item[:payload_arity] || Float::INFINITY,
|
|
244
|
+
loc&.path.to_s, loc&.line || 0, item[:directions], item[:revisions], item[:unknown_reason].to_s]
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module Jobcompat
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
attr_reader :category, :location, :diagnostics
|
|
4
|
+
|
|
5
|
+
def initialize(message, category:, location: nil, diagnostics: nil)
|
|
6
|
+
super(message)
|
|
7
|
+
@category = category
|
|
8
|
+
@location = location
|
|
9
|
+
@diagnostics = diagnostics
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Jobcompat
|
|
4
|
+
module Formatter
|
|
5
|
+
def self.json(envelope)
|
|
6
|
+
JSON.pretty_generate(primitive(envelope)) + "\n"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.primitive(value)
|
|
10
|
+
case value
|
|
11
|
+
when Array then value.map { |item| primitive(item) }
|
|
12
|
+
when Hash then value.transform_values { |item| primitive(item) }
|
|
13
|
+
when Location then value.as_json
|
|
14
|
+
else value
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.text(envelope)
|
|
19
|
+
return envelope[:diagnostics].map { |item| safe_line("#{item[:category]}: #{item[:message]}") }.join("\n") + "\n" if envelope[:status] == "failed"
|
|
20
|
+
base = envelope[:comparison][:base]
|
|
21
|
+
head = envelope[:comparison][:head]
|
|
22
|
+
summary = envelope[:summary]
|
|
23
|
+
lines = ["jobcompat #{VERSION}", "Comparing #{base[:ref]} (#{base[:sha][0, 7]}) -> #{head[:ref]} (#{head[:sha][0, 7]})", "Deployment model: rolling", ""]
|
|
24
|
+
if envelope[:findings].empty?
|
|
25
|
+
lines << (summary[:warnings].positive? ? "PASS WITH WARNINGS" : "PASS: no compatibility errors or warnings found.")
|
|
26
|
+
else
|
|
27
|
+
envelope[:findings].each do |finding|
|
|
28
|
+
lines << "#{finding[:severity].upcase} #{finding[:rule_id]} #{finding[:worker] || '(unknown worker)'}"
|
|
29
|
+
lines << " #{finding[:message]}"
|
|
30
|
+
lines << " Revisions: #{finding[:revisions].join(', ')}"
|
|
31
|
+
lines << " Reason: #{finding[:unknown_reason]}" if finding[:unknown_reason]
|
|
32
|
+
lines << " Affected directions:"
|
|
33
|
+
finding[:directions].each { |direction| lines << " #{direction_label(direction)}" }
|
|
34
|
+
lines << " Risk: #{finding[:risk]}"
|
|
35
|
+
finding[:locations].each do |location|
|
|
36
|
+
lines << " #{location.revision} #{location.path}:#{location.line}:#{location.column} (#{location.role})"
|
|
37
|
+
end
|
|
38
|
+
lines << " Suggested migration:"
|
|
39
|
+
finding[:remediation].each_with_index { |step, index| lines << " #{index + 1}. #{step}" }
|
|
40
|
+
lines << ""
|
|
41
|
+
end
|
|
42
|
+
lines << "PASS WITH WARNINGS" if summary[:errors].zero?
|
|
43
|
+
end
|
|
44
|
+
unless envelope[:suppressions].empty?
|
|
45
|
+
lines << "Suppressed findings:"
|
|
46
|
+
envelope[:suppressions].each { |item| lines << " #{item[:rule_id]} #{item[:worker]} (#{item[:finding_count]}): #{item[:reason]}" }
|
|
47
|
+
lines << ""
|
|
48
|
+
end
|
|
49
|
+
lines << "Summary: #{summary[:errors]} #{summary[:errors] == 1 ? 'error' : 'errors'}, #{summary[:warnings]} #{summary[:warnings] == 1 ? 'warning' : 'warnings'}, #{summary[:suppressed]} suppressed"
|
|
50
|
+
lines.map { |line| safe_line(line) }.join("\n") + "\n"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.safe_line(line)
|
|
54
|
+
line.to_s.gsub(/[[:cntrl:]\p{Cf}]/) do |character|
|
|
55
|
+
character.ord <= 0xFF ? format("\\x%02X", character.ord) : format("\\u{%04X}", character.ord)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.direction_label(value)
|
|
60
|
+
{"base_to_head" => "base producer -> HEAD consumer", "head_to_base" => "HEAD producer -> base consumer",
|
|
61
|
+
"head_to_head" => "HEAD producer -> HEAD consumer"}.fetch(value)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Jobcompat
|
|
4
|
+
TreeEntry = Data.define(:path, :oid, :size)
|
|
5
|
+
|
|
6
|
+
class GitRepository
|
|
7
|
+
GIT_ENV = {"GIT_OPTIONAL_LOCKS" => "0", "GIT_NO_LAZY_FETCH" => "1"}.freeze
|
|
8
|
+
attr_reader :root
|
|
9
|
+
|
|
10
|
+
def initialize(directory)
|
|
11
|
+
output, _error, status = Open3.capture3(GIT_ENV, "git", "rev-parse", "--show-toplevel", chdir: directory)
|
|
12
|
+
raise Error.new("Not inside a Git repository.", category: "git_error") unless status.success?
|
|
13
|
+
@root = output.delete_suffix("\n")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def resolve(ref, label)
|
|
17
|
+
output, status = command("rev-parse", "--verify", "--end-of-options", "#{ref}^{commit}")
|
|
18
|
+
raise Error.new("#{label.capitalize} ref '#{ref}' does not resolve to a commit.", category: "git_error") unless status.success? && output.strip.match?(/\A[0-9a-f]{40,64}\z/)
|
|
19
|
+
output.strip
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def entries(sha)
|
|
23
|
+
output, status = command("ls-tree", "-r", "-l", "-z", "--full-tree", sha)
|
|
24
|
+
raise Error.new("Could not read Git tree #{sha}.", category: "git_error") unless status.success?
|
|
25
|
+
output.split("\0").filter_map do |record|
|
|
26
|
+
metadata, path = record.split("\t", 2)
|
|
27
|
+
next unless path
|
|
28
|
+
mode, type, oid, size = metadata.split(" ")
|
|
29
|
+
next unless %w[100644 100755].include?(mode) && type == "blob"
|
|
30
|
+
raise Error.new("Git tree contains an unavailable blob.", category: "git_error") unless size&.match?(/\A\d+\z/)
|
|
31
|
+
TreeEntry.new(path, oid, Integer(size))
|
|
32
|
+
end.sort_by(&:path)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def read_blobs(entries)
|
|
36
|
+
result = {}
|
|
37
|
+
each_blob(entries) { |entry, data| result[entry.path] = data }
|
|
38
|
+
result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def each_blob(entries)
|
|
42
|
+
return if entries.empty?
|
|
43
|
+
Open3.popen3(GIT_ENV, "git", "cat-file", "--batch", chdir: root) do |input, output, error, wait|
|
|
44
|
+
error_reader = Thread.new do
|
|
45
|
+
error.read
|
|
46
|
+
rescue IOError
|
|
47
|
+
""
|
|
48
|
+
end
|
|
49
|
+
begin
|
|
50
|
+
entries.each do |entry|
|
|
51
|
+
input.write("#{entry.oid}\n")
|
|
52
|
+
input.flush
|
|
53
|
+
header = output.gets
|
|
54
|
+
match = header&.match(/\A([0-9a-f]{40,64}) blob (\d+)\n\z/)
|
|
55
|
+
raise Error.new("Invalid Git blob response.", category: "git_error") unless match && match[1] == entry.oid
|
|
56
|
+
data = output.read(match[2].to_i)
|
|
57
|
+
delimiter = output.read(1)
|
|
58
|
+
raise Error.new("Incomplete Git blob response.", category: "git_error") unless data&.bytesize == match[2].to_i && delimiter == "\n"
|
|
59
|
+
break if yield(entry, data) == :stop
|
|
60
|
+
end
|
|
61
|
+
ensure
|
|
62
|
+
input.close unless input.closed?
|
|
63
|
+
end
|
|
64
|
+
error_reader.value
|
|
65
|
+
raise Error.new("Git blob reader failed.", category: "git_error") unless wait.value.success?
|
|
66
|
+
end
|
|
67
|
+
rescue IOError, Errno::EPIPE
|
|
68
|
+
raise Error.new("Git blob reader failed.", category: "git_error")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def command(*args)
|
|
74
|
+
output, _error, status = Open3.capture3(GIT_ENV, "git", *args, chdir: root)
|
|
75
|
+
[output, status]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/jobcompat.rb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
require_relative "jobcompat/version"
|
|
2
|
+
require_relative "jobcompat/errors"
|
|
3
|
+
require_relative "jobcompat/config"
|
|
4
|
+
require_relative "jobcompat/git_repository"
|
|
5
|
+
require_relative "jobcompat/analysis"
|
|
6
|
+
require_relative "jobcompat/engine"
|
|
7
|
+
require_relative "jobcompat/formatter"
|
|
8
|
+
require_relative "jobcompat/cli"
|
metadata
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: jobcompat
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- jobcompat contributors
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: prism
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.9'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '2'
|
|
22
|
+
type: :runtime
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '1.9'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '2'
|
|
32
|
+
description: Static analysis of native Sidekiq positional arity across Git revisions
|
|
33
|
+
and rolling deployments.
|
|
34
|
+
executables:
|
|
35
|
+
- jobcompat
|
|
36
|
+
extensions: []
|
|
37
|
+
extra_rdoc_files: []
|
|
38
|
+
files:
|
|
39
|
+
- CHANGELOG.md
|
|
40
|
+
- LICENSE
|
|
41
|
+
- README.md
|
|
42
|
+
- SECURITY.md
|
|
43
|
+
- docs/architecture.md
|
|
44
|
+
- docs/competitive-analysis.md
|
|
45
|
+
- docs/implementation-plan.md
|
|
46
|
+
- docs/release-checklist-v0.1.0.md
|
|
47
|
+
- docs/release-notes-v0.1.0.md
|
|
48
|
+
- docs/spec-v0.1.md
|
|
49
|
+
- exe/jobcompat
|
|
50
|
+
- lib/jobcompat.rb
|
|
51
|
+
- lib/jobcompat/analysis.rb
|
|
52
|
+
- lib/jobcompat/cli.rb
|
|
53
|
+
- lib/jobcompat/config.rb
|
|
54
|
+
- lib/jobcompat/engine.rb
|
|
55
|
+
- lib/jobcompat/errors.rb
|
|
56
|
+
- lib/jobcompat/formatter.rb
|
|
57
|
+
- lib/jobcompat/git_repository.rb
|
|
58
|
+
- lib/jobcompat/version.rb
|
|
59
|
+
homepage: https://github.com/cottondesu/jobcompat
|
|
60
|
+
licenses:
|
|
61
|
+
- MIT
|
|
62
|
+
metadata:
|
|
63
|
+
rubygems_mfa_required: 'true'
|
|
64
|
+
source_code_uri: https://github.com/cottondesu/jobcompat
|
|
65
|
+
changelog_uri: https://github.com/cottondesu/jobcompat/blob/main/CHANGELOG.md
|
|
66
|
+
bug_tracker_uri: https://github.com/cottondesu/jobcompat/issues
|
|
67
|
+
rdoc_options: []
|
|
68
|
+
require_paths:
|
|
69
|
+
- lib
|
|
70
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '3.3'
|
|
75
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: '0'
|
|
80
|
+
requirements: []
|
|
81
|
+
rubygems_version: 4.0.16
|
|
82
|
+
specification_version: 4
|
|
83
|
+
summary: Detect Sidekiq job argument changes that can break queued jobs
|
|
84
|
+
test_files: []
|