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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module Jobcompat
2
+ VERSION = "0.1.0"
3
+ 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: []