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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +32 -5
- data/docs/architecture.md +46 -6
- data/docs/implementation-plan-v0.2.1.md +17 -0
- data/docs/implementation-plan-v0.3.md +32 -0
- data/docs/release-checklist-v0.2.1.md +76 -0
- data/docs/release-checklist-v0.3.0.md +109 -0
- data/docs/release-notes-v0.2.1.md +61 -0
- data/docs/release-notes-v0.3.0.md +115 -0
- data/docs/spec-v0.2.1.md +65 -0
- data/docs/spec-v0.3.md +238 -0
- data/lib/jobcompat/aliases.rb +86 -0
- data/lib/jobcompat/analysis.rb +78 -38
- data/lib/jobcompat/canonical_worker_name.rb +35 -0
- data/lib/jobcompat/cli.rb +1 -1
- data/lib/jobcompat/config.rb +2 -6
- data/lib/jobcompat/engine.rb +104 -36
- data/lib/jobcompat/version.rb +1 -1
- data/lib/jobcompat.rb +1 -0
- metadata +11 -1
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:
|
|
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}
|
data/lib/jobcompat/config.rb
CHANGED
|
@@ -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
|
|
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]
|
data/lib/jobcompat/engine.rb
CHANGED
|
@@ -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
|
|
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
|
-
@
|
|
17
|
-
@calls = {"base" => resolve(base
|
|
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" =>
|
|
22
|
-
"base" =>
|
|
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.
|
|
34
|
-
h = head.
|
|
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
|
|
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
|
|
45
|
-
b.declaration_locations + b
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
126
|
-
|
|
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.
|
|
143
|
+
ConstantLookup.candidates(call.receiver, false, call.namespace)
|
|
136
144
|
end
|
|
137
|
-
worker = candidates
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
data/lib/jobcompat/version.rb
CHANGED
data/lib/jobcompat.rb
CHANGED
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.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
|