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.
data/docs/spec-v0.1.md ADDED
@@ -0,0 +1,1157 @@
1
+ # jobcompat v0.1 formal specification
2
+
3
+ Status: frozen implementation-ready specification
4
+ Specification date: 2026-09-23
5
+ Target release: `0.1.0`
6
+
7
+ The key words **MUST**, **MUST NOT**, **SHOULD**, and **MAY** are normative.
8
+
9
+ ## 1. Product definition
10
+
11
+ `jobcompat` is an offline-friendly command-line static analyzer for detecting positional-arity breaking changes in queued native Sidekiq jobs between two Git revisions.
12
+
13
+ One-line pitch:
14
+
15
+ > Breaking-change detector for queued background jobs.
16
+
17
+ Precise v0.1 positioning:
18
+
19
+ > A Git-aware static compatibility check for native Sidekiq job arity across rolling deployments.
20
+
21
+ The analyzer treats a background job as an asynchronous contract between:
22
+
23
+ - a **producer**, which persists a class name and positional payload arguments; and
24
+ - a **consumer**, the job class's instance `perform` method, which may run later or on a different deployed revision.
25
+
26
+ ## 2. Goals
27
+
28
+ v0.1 MUST:
29
+
30
+ 1. compare an explicit base Git ref with a head Git ref (default `HEAD`);
31
+ 2. leave the index and working tree unchanged;
32
+ 3. discover direct native Sidekiq job classes from Ruby ASTs;
33
+ 4. discover supported native Sidekiq enqueue calls from Ruby ASTs;
34
+ 5. model positional `perform` arity exactly as an integer interval;
35
+ 6. evaluate base producer → head consumer, head producer → base consumer, and head producer → head consumer;
36
+ 7. report proven incompatibilities as errors and incomplete proof as warnings;
37
+ 8. provide deterministic text and stable JSON output;
38
+ 9. run without Rails boot, application execution, Redis, or a Sidekiq process;
39
+ 10. be suitable for a CI merge gate.
40
+
41
+ ## 3. Non-goals
42
+
43
+ The following are explicitly out of scope for v0.1:
44
+
45
+ - ActiveJob, BullMQ, Celery, SQS, Cloudflare Queues, Resque, GoodJob, and Solid Queue;
46
+ - live Redis inspection or reading actual queued/retry/scheduled/dead jobs;
47
+ - Rails boot, Bundler application loading, autoloading, or executing repository code;
48
+ - `Sidekiq::Client.push`, `push_bulk`, `perform_bulk`, `bulk_perform_async`, or custom wrappers;
49
+ - queue rename/migration analysis;
50
+ - Hash-internal schemas, required/optional Hash keys, or nested payload structure;
51
+ - value types, JSON-serialization safety, Sorbet, RBS, or RBI compatibility;
52
+ - keyword-parameter compatibility for `perform`;
53
+ - arbitrary metaprogramming, generated methods, `define_method`, or `class_eval`;
54
+ - inheritance-, concern-, prepend-, extend-, or alias-based Sidekiq job discovery;
55
+ - arbitrary constant lookup, runtime aliases, dependency injection, or feature-flag evaluation;
56
+ - inter-procedural data flow, variable value propagation, or call graph construction;
57
+ - rename inference or automatic mapping between old and new worker names;
58
+ - repository working-tree changes as an analysis snapshot;
59
+ - SARIF, GitHub annotations, autofix, or an `init` command.
60
+
61
+ Absence from source evidence MUST NOT be represented as proof that a queue is empty. The static class-presence proof below concerns tracked Ruby declarations, not runtime constant loading or Redis contents.
62
+
63
+ ## 4. Runtime and supported target
64
+
65
+ | Item | v0.1 decision |
66
+ | --- | --- |
67
+ | Implementation | Ruby gem and Ruby CLI |
68
+ | Required Ruby | `>= 3.3` |
69
+ | Parser | native Prism AST, runtime dependency `prism >= 1.9, < 2` |
70
+ | Framework target | native Sidekiq source patterns only |
71
+ | Job modules | `Sidekiq::Job`, plus legacy `Sidekiq::Worker` |
72
+ | Deployment model | rolling deployment with base and head processes potentially coexisting |
73
+ | Analysis | static, whole-snapshot, offline after dependencies and Git objects exist |
74
+ | OSS license | MIT, copyright `2026 jobcompat contributors` |
75
+
76
+ Ruby 3.2 is not selected even though current Sidekiq 8 accepts it, because Ruby 3.2 reached EOL on 2026-04-01. Ruby 3.3 is in security maintenance and Prism is a default gem in Ruby 3.3+. The explicit Prism dependency supplies a consistent supported API across Ruby 3.3, 3.4, and 4.x.
77
+
78
+ `sidekiq` MUST NOT be a jobcompat runtime dependency. Source recognition does not require loading Sidekiq.
79
+
80
+ ## 5. Snapshot semantics
81
+
82
+ The command compares two committed snapshots:
83
+
84
+ - `base`: required user-supplied ref;
85
+ - `head`: `HEAD` unless explicitly supplied.
86
+
87
+ Both refs MUST resolve to commits using the equivalent of:
88
+
89
+ ```text
90
+ git rev-parse --verify --end-of-options <ref>^{commit}
91
+ ```
92
+
93
+ All subsequent reads MUST use the resolved full commit SHA, not the mutable ref string. Uncommitted and staged changes are intentionally ignored. The text and JSON output MUST include both requested refs and resolved SHAs.
94
+
95
+ The analyzer MUST NOT run `checkout`, `switch`, `reset`, `stash`, or any command that changes repository state.
96
+
97
+ ## 6. Source selection
98
+
99
+ A Git blob is scanned when all conditions hold:
100
+
101
+ 1. its tree mode is a regular file (`100644` or `100755`);
102
+ 2. its repository-relative path matches at least one `scan.include` glob;
103
+ 3. it matches no `scan.exclude` glob.
104
+
105
+ Paths use `/` separators, are relative to the Git root, and are matched case-sensitively. Exclude wins over include. Symlinks, submodules, and non-blob entries MUST be skipped.
106
+
107
+ Glob matching uses `File.fnmatch?` with `File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH` after path normalization. `FNM_CASEFOLD`/`FNM_SYSCASE` are not used. Thus `**/*.rb` includes a root-level Ruby file and Ruby files below dot-directories unless an exclude removes them; `*` never crosses `/`.
108
+
109
+ Default globs:
110
+
111
+ ```yaml
112
+ scan:
113
+ include:
114
+ - "**/*.rb"
115
+ exclude:
116
+ - "vendor/**"
117
+ - "tmp/**"
118
+ - "log/**"
119
+ - "coverage/**"
120
+ - ".bundle/**"
121
+ - "test/**"
122
+ - "spec/**"
123
+ - "features/**"
124
+ - "examples/**"
125
+ ```
126
+
127
+ Test/example directories are excluded by default because calls there do not prove production enqueue capability. A configured `include` or `exclude` array replaces that default array; it does not append. The README MUST warn users to copy any defaults they still want when overriding.
128
+
129
+ The normal scan selects workers and producers. The presence-only pass in §7.5 is separate: a class moved to a tracked `.rb` file outside these globs MUST NOT be reported as removed solely because the normal scan no longer selects it.
130
+
131
+ ## 7. Worker discovery
132
+
133
+ ### 7.1 Supported declarations
134
+
135
+ The analyzer MUST recognize exact constant arguments to a direct `include` call inside a class body:
136
+
137
+ ```ruby
138
+ class ExportJob
139
+ include Sidekiq::Job
140
+ end
141
+ ```
142
+
143
+ ```ruby
144
+ module Admin
145
+ class ExportJob
146
+ include Sidekiq::Job
147
+ end
148
+ end
149
+ ```
150
+
151
+ ```ruby
152
+ class Admin::ExportJob
153
+ include Sidekiq::Job
154
+ end
155
+ ```
156
+
157
+ ```ruby
158
+ class ExportWorker
159
+ include Sidekiq::Worker
160
+ end
161
+ ```
162
+
163
+ Root-qualified constants and parentheses MUST also work:
164
+
165
+ ```ruby
166
+ include(::Sidekiq::Job)
167
+ ```
168
+
169
+ If an `include` call has multiple arguments, a class is a worker when any argument is exactly `Sidekiq::Job`, `::Sidekiq::Job`, `Sidekiq::Worker`, or `::Sidekiq::Worker`.
170
+
171
+ ### 7.2 Canonical names
172
+
173
+ Canonical worker names contain no leading `::`:
174
+
175
+ ```text
176
+ ExportJob
177
+ Admin::ExportJob
178
+ ```
179
+
180
+ For nested single-segment `module`/`class` syntax, lexical names are joined with `::`. A root-qualified explicit class path is exact. An unrooted multi-segment class/module path such as `class Admin::ExportJob` is supported only at top level; inside another lexical module it is unsupported because Ruby constant lookup could select either a lexical or top-level prefix. A constant path whose parent is not a static constant path is also unsupported.
181
+
182
+ ### 7.3 Reopened classes
183
+
184
+ Class fragments with the same canonical name in one revision MUST be grouped. A worker contract is known when:
185
+
186
+ - at least one fragment directly includes a recognized Sidekiq module; and
187
+ - exactly one direct instance `def perform` exists across all fragments.
188
+
189
+ This permits one file to include Sidekiq and another to define `perform`. Zero or multiple direct `perform` definitions produce JC007 because inheritance, generation, and load order cannot be proven safely.
190
+
191
+ Fragments are merged by canonical class name within each snapshot, independent of file path and traversal order. Moving or renaming only the include fragment or only the `perform` fragment does not remove the worker when both remain selected. A known contract records every relevant fragment location. In v0.1, a supported worker-contract fragment conflict is limited to directly recognized class fragments of the same canonical Sidekiq worker whose merged direct `perform` definitions cannot yield one deterministic positional contract. Multiple direct `perform` definitions use the existing `multiple_perform_definitions` JC007 reason; zero direct definitions use `missing_perform`. Neither case proves class removal or permits JC004.
192
+
193
+ This fragment-merge rule does not reconcile general Ruby class/module kind, superclass, constant, autoload, reassignment, or runtime load-order conflicts. Such a conflict alone does not create a new JC007 reason or finding, and its omission does not prove runtime safety, general compatibility, or class deletion. Other unsupported consumer syntax continues to use the existing reasons in §8.3.
194
+
195
+ ### 7.4 Explicit limitations
196
+
197
+ The following MUST NOT cause worker recognition:
198
+
199
+ ```ruby
200
+ class ExportJob < ApplicationJob # inheritance
201
+ end
202
+
203
+ class ExportJob
204
+ include SidekiqConcern # indirect concern
205
+ end
206
+
207
+ ExportJob.include(Sidekiq::Job) # external mutation
208
+
209
+ ExportJob = Class.new do # dynamic class
210
+ include Sidekiq::Job
211
+ end
212
+ ```
213
+
214
+ An unsupported static class/module path whose body directly includes `Sidekiq::Job` or `Sidekiq::Worker` MUST yield JC007 with `worker: null`; jobcompat knows it is intended as a worker but cannot prove its canonical name. Other unsupported class patterns MAY yield JC007 only when they can be associated with an otherwise recognized worker. The analyzer MUST NOT invent a worker name from arbitrary metaprogramming.
215
+
216
+ ### 7.5 DefinedConstantIndex and removal proof
217
+
218
+ Each snapshot has a lightweight `DefinedConstantIndex`, separate from worker recognition. It records canonical names of statically declared Ruby classes/modules, their locations, and whether each declaration came from the normal scan or a presence-only pass. It also records statically named constant assignments and ambiguous class/module paths as possible bindings. Indexing uses Prism on Git blobs; it MUST NOT execute application code or infer arbitrary runtime aliases. A module or possible binding with the candidate name blocks proof that the name disappeared, even though it does not establish a supported worker.
219
+
220
+ The index has five query outcomes for a canonical name: `recognized_worker`, `defined_unrecognized`, `outside_scan_scope`, `absent`, or `unverified`; `recognized_worker` is supplied by worker discovery, while the other outcomes come from the index. `defined_unrecognized` means a selected class/module or possible binding exists but a supported worker contract is not recognized. `outside_scan_scope` means an exact declaration or possible binding is in tracked Ruby outside normal scan selection. `absent` means the complete presence check found no exact or plausible declaration/binding. `unverified` means the pass could not exclude one because of an ambiguous path, parse/encoding failure, or resource budget. Both `defined_unrecognized` and `outside_scan_scope` are presence evidence, not proof of a runnable Sidekiq worker.
221
+
222
+ Presence checking is lazy for names that would otherwise trigger JC004 or JC005. A head-only class without a head enqueue needs no base presence query and is displayed as `not_checked`; it produces no JC005. Reuse selected-file ASTs. For remaining regular tracked paths ending in `.rb` (case-sensitive), regardless of configured include/exclude, stream raw bytes in path order and look for the candidate leaf constant token; only blobs containing it need Prism parsing. A static declaration or named binding of that class cannot omit its literal leaf token. For a non-ASCII candidate leaf, skip the byte prefilter and parse all unselected tracked `.rb` blobs within the same budget; if the pass cannot finish, return `unverified`. The byte prefilter is solely an optimization; it cannot itself prove presence. The pass scans at most 64 MiB (67,108,864 bytes) of unselected Ruby blob bytes per snapshot, counting each path's full blob size. If the next blob would exceed the limit before all are checked, remaining candidates become `unverified`, not `absent`. For an unselected blob with a candidate leaf token, a Prism parse/encoding error or ambiguous canonical path likewise yields `unverified` for affected candidates, without turning an excluded file into a global parse error. Selected-file parse errors still exit 2. The loader streams data and does not keep the excluded tree in memory.
223
+
224
+ `absent` is a proof only within this static tracked-`.rb` declaration/binding model. Dynamic `const_set`, autoload, code outside tracked `.rb` files, and runtime reassignment are outside v0.1; they are not claimed to be absent at runtime. If the index cannot complete its defined static proof, the corresponding structural rule MUST NOT emit ERROR. A missing recognized worker with `defined_unrecognized`, `outside_scan_scope`, or `unverified` yields one JC007 for that transition, with the relevant source location when available and the base/head worker declaration as supporting evidence. No ordinary finding is emitted merely because a non-worker class exists outside scan scope.
225
+
226
+ ### 7.6 File moves and canonical names
227
+
228
+ | Change between base and head | Required result |
229
+ | --- | --- |
230
+ | `app/jobs/export_job.rb` becomes `app/workers/export_job.rb`, canonical name and contract unchanged | no finding; file paths alone are not identities |
231
+ | include or `perform` fragment of a reopened class moves or one fragment file is renamed, while all fragments remain selected | merge by canonical name; unchanged contract yields no finding |
232
+ | `ExportJob` becomes `GenerateExportJob`, old name proven absent from head tracked `.rb` | JC004 for old name; do not infer a rename relation |
233
+ | newly named worker also has head enqueue and its name is proven absent in base tracked `.rb` | conditional-risk JC005 for new name in addition to JC004 |
234
+ | old canonical class remains in tracked head `.rb` outside normal scan scope | JC007 `outside_analysis_scope`, never JC004 |
235
+
236
+ These are snapshot-level rules. No Git rename detection is required: each revision is indexed independently, and paths are evidence locations rather than worker identity.
237
+
238
+ ## 8. Worker contract model
239
+
240
+ ### 8.1 Mathematical model
241
+
242
+ For a known worker contract, accepted positional arities form an interval:
243
+
244
+ ```text
245
+ A = { n ∈ ℕ₀ | min_arity ≤ n ≤ max_arity }
246
+ ```
247
+
248
+ If `max_arity` is unbounded:
249
+
250
+ ```text
251
+ A = { n ∈ ℕ₀ | min_arity ≤ n }
252
+ ```
253
+
254
+ Internal and JSON representation uses `max_arity: null` for positive infinity. `variadic` is true exactly when `max_arity` is null.
255
+
256
+ Examples:
257
+
258
+ | Signature | Accepted set | Model |
259
+ | --- | --- | --- |
260
+ | `def perform` | `{0}` | min 0, max 0 |
261
+ | `def perform(user_id)` | `{1}` | min 1, max 1 |
262
+ | `def perform(user_id, format = nil)` | `{1,2}` | min 1, max 2 |
263
+ | `def perform(a, b = nil, c = nil)` | `{1,2,3}` | min 1, max 3 |
264
+ | `def perform(user_id, *args)` | `{1,2,3,...}` | min 1, max null |
265
+ | `def perform(*args)` | `{0,1,2,...}` | min 0, max null |
266
+ | `def perform(a, *rest, z)` | `{2,3,4,...}` | min 2, max null |
267
+ | `def perform(...)` | `{0,1,2,...}` | min 0, max null, signature kind `forwarding` |
268
+ | `def perform(a, ...)` | `{1,2,3,...}` | min 1, max null, signature kind `forwarding` |
269
+
270
+ Required destructured positional parameters count as one positional argument. A block parameter (`&block`) does not change positional arity.
271
+
272
+ Parameter names are not contract elements. This change is compatible:
273
+
274
+ ```diff
275
+ -def perform(user_id)
276
+ +def perform(account_id)
277
+ ```
278
+
279
+ ### 8.2 Prism parameter calculation
280
+
281
+ For a `Prism::ParametersNode` with no unsupported keywords:
282
+
283
+ ```text
284
+ required_count = requireds.length + posts.length
285
+ optional_count = optionals.length
286
+ min_arity = required_count
287
+ max_arity = rest ? null : required_count + optional_count
288
+ ```
289
+
290
+ `parameters == nil` means `[0,0]`. `RestParameterNode` makes max unbounded. A `ForwardingParameterNode` makes max unbounded while retaining leading required positional parameters.
291
+
292
+ ### 8.3 Unsupported consumer signatures
293
+
294
+ Any of these make the worker contract unknown and MUST lead to coalesced JC007 warnings rather than a compatibility pass:
295
+
296
+ - required keyword parameters;
297
+ - optional keyword parameters;
298
+ - named keyword rest (`**kwargs`);
299
+ - `**nil`;
300
+ - multiple `perform` definitions;
301
+ - no direct `perform` definition;
302
+ - a parameter node shape not covered by this specification.
303
+
304
+ The serialized consumer `unknown_reason` values are fixed for schema v1:
305
+
306
+ ```text
307
+ missing_perform
308
+ multiple_perform_definitions
309
+ keyword_parameters
310
+ unsupported_parameters
311
+ ```
312
+
313
+ `def perform(...)` is supported because the outer method accepts any positional payload count. jobcompat does not analyze whether its body forwards those arguments into a narrower downstream method.
314
+
315
+ ## 9. Producer discovery
316
+
317
+ ### 9.1 Supported calls
318
+
319
+ The following direct calls MUST be recognized:
320
+
321
+ ```ruby
322
+ ExportJob.perform_async(user_id)
323
+ ExportJob.perform_in(5.minutes, user_id)
324
+ ExportJob.perform_at(time, user_id)
325
+ ExportJob.set(queue: :critical).perform_async(user_id)
326
+ ```
327
+
328
+ Supported receiver constants include top-level, qualified, and root-qualified forms.
329
+
330
+ ### 9.2 Payload arity
331
+
332
+ For `perform_async`, every syntactic argument is one payload argument unless any splat/forwarding argument makes the count unknown.
333
+
334
+ For `perform_in` and `perform_at`, the first argument is the scheduling argument and MUST be excluded. Therefore:
335
+
336
+ ```ruby
337
+ ExportJob.perform_in(5.minutes, user_id, "csv")
338
+ ```
339
+
340
+ emits payload arity 2.
341
+
342
+ An Array literal, Hash literal, or keyword-style Hash is one positional payload element:
343
+
344
+ ```ruby
345
+ Job.perform_async(id, { "format" => "csv" }) # arity 2
346
+ Job.perform_async([id, other_id]) # arity 1
347
+ Job.perform_async # arity 0
348
+ ```
349
+
350
+ Jobcompat does not decide whether values or Hash keys are JSON-safe; Sidekiq's own strict argument checking owns that concern.
351
+
352
+ ### 9.3 Constant receiver resolution
353
+
354
+ Producer extraction is two-stage:
355
+
356
+ 1. collect the syntactic receiver and lexical namespace without guessing;
357
+ 2. resolve it against the union of recognized base/head worker names.
358
+
359
+ Resolution rules:
360
+
361
+ - `::Admin::ExportJob` resolves exactly to `Admin::ExportJob`;
362
+ - any unrooted static receiver, whether `ExportJob` or `Admin::ExportJob`, tries syntactic lexical prefixes from innermost to outermost and then the receiver as written at top level; it selects the first name present in the worker-name union;
363
+ - zero matches means the call is ignored when the receiver is a static constant not known as a worker;
364
+ - more than one equally valid result, a non-constant path parent, safe navigation, or a dynamic receiver is unknown.
365
+
366
+ This is bounded lexical resolution, not general Ruby constant evaluation. Aliases, ancestors, `const_get`, autoload behavior, and runtime reassignments are out of scope.
367
+
368
+ The base/head union permits a head call to a worker that existed only in base to remain attributable, and permits JC005 to attribute a new head worker.
369
+
370
+ ### 9.4 Unknown producer arity and receiver
371
+
372
+ Any argument list containing `SplatNode` or `ForwardingArgumentsNode` has unknown payload arity:
373
+
374
+ ```ruby
375
+ Job.perform_async(*args)
376
+ Job.perform_async(...)
377
+ ```
378
+
379
+ No attempt is made to evaluate literal splats in v0.1.
380
+
381
+ `perform_in`/`perform_at` with no schedule argument or with a splat that prevents separating schedule from payload is unknown.
382
+
383
+ Calls to target method names on dynamic receivers are visible unknowns:
384
+
385
+ ```ruby
386
+ job_class.perform_async(id)
387
+ factory.job.perform_at(time, id)
388
+ ```
389
+
390
+ They produce JC007 with `worker: null`. This can include non-Sidekiq APIs; it is a warning, never an error. v0.1 suppression requires a worker name, so such findings are controlled through scan exclusions rather than a broad ignore.
391
+
392
+ `send`, `public_send`, `Sidekiq::Client.push`, wrapper methods, aliases, and inter-procedural calls are not discovered and do not generate warnings.
393
+
394
+ The serialized producer/call `unknown_reason` values are fixed for schema v1:
395
+
396
+ ```text
397
+ splat_arguments
398
+ forwarded_arguments
399
+ missing_schedule_argument
400
+ dynamic_receiver
401
+ unsupported_constant_path
402
+ safe_navigation_receiver
403
+ ```
404
+
405
+ If more than one reason applies to one call, choose the first applicable value in the order shown. An unsupported consumer uses the consumer reason list instead. These strings drive JC007 aggregation and are serialized as `findings[].unknown_reason`; consumer reasons also appear on unknown contract objects.
406
+
407
+ ### 9.5 `.set` chain
408
+
409
+ v0.1 recognizes exactly an outer `perform_async` whose receiver is an inner `set(...)` call whose receiver resolves to a worker constant. The options passed to `set` do not affect payload arity. Additional chaining and `set(...).perform_in/perform_at` are out of scope for v0.1.
410
+
411
+ ## 10. Compatibility semantics
412
+
413
+ ### 10.1 Directions
414
+
415
+ The engine MUST evaluate:
416
+
417
+ | ID | Direction | Purpose |
418
+ | --- | --- | --- |
419
+ | A | `base_to_head` | old or already queued payload handled by the new worker |
420
+ | B | `head_to_base` | new application node's payload handled by an old worker during rolling deploy |
421
+ | C | `head_to_head` | current source tree's producer/consumer consistency |
422
+
423
+ `base_to_base` MAY be calculated as baseline context in JSON matrices, but MUST NOT produce a v0.1 finding by itself.
424
+
425
+ Every finding has a non-empty `directions` array. Its only values and canonical order are `base_to_head`, `head_to_base`, `head_to_head`; repeated directions are removed. A finding can cover multiple directions when the same proof is owned by one rule. `revisions` is a separate non-empty array of revisions participating in the finding's proof, ordered `base`, then `head`; it does not replace the revision on each proof location. For JC007 specifically, it is the union of snapshots containing the matched root unknown evidence; supporting locations do not add a revision to that array.
426
+
427
+ ### 10.2 Membership
428
+
429
+ For a known producer arity `P` and consumer acceptance interval `A`:
430
+
431
+ ```text
432
+ compatible(P, A) ⇔ P ∈ A
433
+ ```
434
+
435
+ ### 10.3 Contract inclusion
436
+
437
+ A base consumer contract is not narrowed when:
438
+
439
+ ```text
440
+ A_base ⊆ A_head
441
+ ```
442
+
443
+ For intervals, this is true exactly when:
444
+
445
+ ```text
446
+ head.min_arity <= base.min_arity
447
+ and
448
+ (
449
+ head.max_arity is unbounded
450
+ or
451
+ (base.max_arity is finite and base.max_arity <= head.max_arity)
452
+ )
453
+ ```
454
+
455
+ ### 10.4 Matrix status
456
+
457
+ Each producer/consumer cell is one of:
458
+
459
+ - `pass`: at least one known call and all known arities are accepted, with no unknown calls;
460
+ - `fail`: at least one known call arity is rejected;
461
+ - `unknown`: no known rejection, but at least one relevant call or consumer contract is unknown;
462
+ - `not_applicable`: there are no relevant producer calls or a consumer is statically proven absent and the cell cannot represent a membership check. A present-but-unrecognized or unverified consumer with relevant producers is `unknown`, never `pass` or `not_applicable`.
463
+
464
+ Precedence is `fail > unknown > pass > not_applicable`.
465
+
466
+ ### 10.5 No pre-existing-error promotion
467
+
468
+ JC001 and JC002 MUST identify a compatibility regression, not merely repeat a mismatch already present in the baseline/current tree:
469
+
470
+ - JC001 requires the base worker to accept the base producer arity and the head worker to reject it.
471
+ - JC002 requires the head worker to accept the head producer arity and the base worker to reject it.
472
+ - A head producer rejected by the head worker is owned by JC001 when the same `(worker, P)` has a qualifying base producer; otherwise JC003 owns it, even if base also rejects it.
473
+
474
+ ## 11. Rule catalog
475
+
476
+ Rule IDs are public API. Their meanings MUST NOT be repurposed after 0.1.0. Wording may improve without changing detection semantics.
477
+
478
+ ### JC001 — Old payload rejected by new worker
479
+
480
+ - **Severity:** error
481
+ - **Directions:** `base_to_head`, plus `head_to_head` when a head producer of the same `(worker, P)` is also rejected by the same head contract.
482
+ - **Problem:** A known base producer payload that was valid for the base worker is invalid for the head worker.
483
+ - **Preconditions:** worker exists with known contracts in base and head; a base enqueue call has known arity `P`; `P ∈ A_base`; `P ∉ A_head`.
484
+ - **Exact algorithm:** for every base known enqueue call grouped by worker, test membership in both contracts. Emit one finding per distinct `(worker, P)` and aggregate all base producer locations with that arity. If head also enqueues `P` and the same head contract rejects it, add `head_to_head` and all such head producer locations to this JC001; do not emit JC003 for `(worker, P)`. Do not emit if JC004 owns the worker removal.
485
+ - **Unsafe example:** base `perform(id)` and base `perform_async(id)`; head `perform(id, format)`.
486
+ - **Safe example:** head `perform(id, format = nil)`.
487
+ - **Remediation:** make the new worker accept the old payload; deploy it; only later narrow after queue/retry/scheduled retention is safely handled.
488
+ - **False positives:** a discovered base callsite may be unreachable or may never have run; deployment policy may guarantee drained queues.
489
+ - **False negatives:** calls through wrappers, `Sidekiq::Client.push`, bulk APIs, dynamic constants, or older queued arities absent from the base snapshot.
490
+ - **Suppression:** exact `rule: JC001` + canonical `worker`, with non-empty reason. All aggregated JC001 findings for that worker are suppressed.
491
+
492
+ ### JC002 — New payload rejected by old worker
493
+
494
+ - **Severity:** error
495
+ - **Directions:** `head_to_base`.
496
+ - **Problem:** A known head producer emits a payload accepted by the head worker but rejected by the base worker.
497
+ - **Preconditions:** worker exists with known contracts in base and head; head enqueue has known `P`; `P ∈ A_head`; `P ∉ A_base`.
498
+ - **Exact algorithm:** evaluate each distinct head arity against both contracts. Emit one finding per `(worker, P)`, aggregating producer locations. If head also rejects `P`, ownership belongs to JC001 when that `(worker, P)` has a qualifying base producer, otherwise JC003. If the base class is proven absent, JC005 owns the case; if base presence is unverified or a class/binding remains, emit JC007 instead of an absent-class error.
499
+ - **Unsafe example:** base `perform(id)`; head `perform(id, format = nil)` plus head `perform_async(id, "csv")`.
500
+ - **Safe example:** release M adds optional consumer argument only; release M+1 starts producing it after old workers are gone.
501
+ - **Remediation:** split consumer broadening and producer use across deployments, or gate enqueue activation until the old fleet cannot consume jobs.
502
+ - **False positives:** a feature flag or deployment orchestrator may prove the head call cannot execute during overlap.
503
+ - **False negatives:** unsupported producers, aliases, wrappers, or deployment overlap longer than the selected base/head pair.
504
+ - **Suppression:** exact rule+worker with mandatory reason; intended for externally proven rollout gates.
505
+
506
+ ### JC003 — Current producer/consumer mismatch
507
+
508
+ - **Severity:** error
509
+ - **Directions:** `head_to_head` only when the same `(worker, P)` is not already in JC001.
510
+ - **Problem:** A known head producer arity is rejected by its known head worker.
511
+ - **Preconditions:** head worker exists with known contract; head enqueue has known `P`; `P ∉ A_head`.
512
+ - **Exact algorithm:** group head calls by `(worker, P)` and emit once with all callsites only when JC001 does not already own that worker and arity. It takes precedence over JC002 for the same producer evidence.
513
+ - **Unsafe example:** head `perform(id)` and `perform_async(id, "csv")`.
514
+ - **Safe example:** head `perform(id, format = nil)` with one- or two-argument calls.
515
+ - **Remediation:** align enqueue arity with `perform` before merge.
516
+ - **False positives:** a callsite may be unreachable, monkey-patched, or invoke a different runtime constant.
517
+ - **False negatives:** unsupported wrappers and dynamic dispatch.
518
+ - **Suppression:** exact rule+worker; use sparingly because this is a same-revision mismatch.
519
+
520
+ ### JC004 — Worker class absent from HEAD source
521
+
522
+ - **Severity:** error
523
+ - **Directions:** `base_to_head`.
524
+ - **Problem:** A canonical supported Sidekiq worker in base has no corresponding class/module declaration or statically named binding in HEAD's tracked `.rb` source under the defined static presence model. Queues, retries, and scheduled jobs may retain its serialized old class name.
525
+ - **Preconditions:** worker recognized in base; no supported head worker of that name; HEAD `DefinedConstantIndex` returns `absent` after the complete presence-only pass.
526
+ - **Exact algorithm:** take `workers_base.keys - workers_head.keys`, then query HEAD presence for each candidate. Emit once per name only for `absent`. For `defined_unrecognized`, `outside_scan_scope`, or `unverified`, emit JC007 instead. JC004 owns the proven absent head consumer, so do not synthesize JC001 or JC003 for that absence. Attach any head callsites still targeting the absent worker as supporting locations.
527
+ - **Unsafe example:** delete or rename `ExportJob` in one release.
528
+ - **Safe example:** retain `ExportJob` as a delegating compatibility shell until the retention window is over, then remove it in a later release.
529
+ - **Remediation:** use a staged removal/rename and preserve an old class entry point long enough for queued, retry, and scheduled jobs.
530
+ - **False positives:** an operator may have authoritatively drained all relevant Redis sets and disabled all old producers; dynamically defined or externally loaded runtime constants are outside the static source model.
531
+ - **False negatives:** indirect/inherited workers not recognized in base.
532
+ - **Suppression:** exact rule+worker with a reason documenting the drain/retention guarantee.
533
+
534
+ JC004 is an error because the previously recognized serialized class name has been proven absent from HEAD's tracked `.rb` declarations/bindings within the defined static model. It does not claim that an old job definitely exists in Redis. Losing worker recognition alone never qualifies.
535
+
536
+ ### JC005 — New worker enqueued before old fleet can understand it
537
+
538
+ - **Severity:** error
539
+ - **Directions:** `head_to_base`.
540
+ - **Problem:** head introduces a supported worker and a known head producer can enqueue it while the base class is statically proven absent. If an old Sidekiq process can consume that job during the rolling deployment, it cannot resolve the new worker class; production failure is conditional on that assignment.
541
+ - **Preconditions:** worker absent from recognized base workers, present in head, at least one head enqueue call has a statically resolved target name matching it, and base `DefinedConstantIndex` returns `absent`. Payload arity may be known or unknown because the old class is absent either way.
542
+ - **Exact algorithm:** for each `workers_head.keys - workers_base.keys`, find all attributable head enqueue calls and query base presence. Emit one finding per worker aggregating all calls only if presence is `absent`. Do not emit for a new worker with no discovered head enqueue. If base presence is `defined_unrecognized`, `outside_scan_scope`, or `unverified`, use JC007, not an absent-class ERROR. If a call's payload arity is unknown, JC005 owns `head_to_base`; JC007 may still report that `head_to_head` compatibility is unproven.
543
+ - **Unsafe example:** add `GenerateReportJob` and immediately call `GenerateReportJob.perform_async(id)` in the same rolling release.
544
+ - **Safe example:** deploy the worker class first; begin enqueueing in a later release, or use an externally controlled post-deploy gate.
545
+ - **Remediation:** separate class availability from producer activation.
546
+ - **False positives:** queue isolation, an old fleet that does not consume that queue, a feature flag that delays enqueue activation, or other deployment sequencing may prevent old-process consumption. v0.1 does not evaluate these controls.
547
+ - **False negatives:** dynamic/wrapper enqueue calls or workers delivered outside the analyzed repository.
548
+ - **Suppression:** exact rule+worker with mandatory rollout-gate reason; it can document externally proven queue or deployment controls.
549
+
550
+ ### JC006 — Worker contract narrowed without sufficient producer evidence
551
+
552
+ - **Severity:** warning
553
+ - **Directions:** `base_to_head`.
554
+ - **Problem:** the head worker no longer accepts the full base arity set, but known base callsites do not prove that a removed arity was produced. No repository producer callsite found does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists. The head interval may also add other arities; it need not be a strict subset of the base interval.
555
+ - **Preconditions:** known worker contracts exist in base and head; `A_base ⊄ A_head`; no known base producer `P` satisfies `P ∈ A_base` and `P ∉ A_head`; no JC003 head producer has `P ∈ A_base` and `P ∉ A_head`; worker is not removed.
556
+ - **Exact algorithm:** perform interval inclusion once per worker after JC001/JC003 evidence ownership. Emit one worker-level warning and describe `A_base ∖ A_head` as one or two removed integer ranges. Unknown base producers do not turn this into an error.
557
+ - **Unsafe example:** base `perform(id, format = nil)` becomes head `perform(id)` with no two-argument base callsite found.
558
+ - **Safe example:** base `perform(id)` becomes head `perform(id, format = nil)`, which broadens the contract.
559
+ - **Remediation:** retain the broader signature until the maximum queue/retry/schedule lifetime has elapsed, or document and suppress a proven drain.
560
+ - **False positives:** the removed arity may never have been used and no such job may remain.
561
+ - **False negatives:** a contract can remain arity-compatible while becoming semantically or type-incompatible.
562
+ - **Suppression:** exact rule+worker with reason.
563
+
564
+ ### JC007 — Compatibility could not be proven
565
+
566
+ - **Severity:** warning
567
+ - **Directions:** derived from the unknown evidence as defined below.
568
+ - **Problem:** visible source evidence cannot be reduced to a known worker name, producer arity, or consumer interval.
569
+ - **Preconditions:** one of the explicitly unsupported/unknown visible cases occurs: splat/forwarded enqueue arguments, dynamic receiver on a target enqueue method, unsupported `perform` keywords, zero/multiple `perform` definitions, malformed scheduled call, unsupported static constant path, or a class-presence transition that is `defined_unrecognized`, `outside_scan_scope`, or `unverified`.
570
+ - **Exact algorithm:** create one finding per semantic root unknown evidence fingerprint (§12), coalescing affected directions and revisions. A base producer unknown affects `base_to_head`; a head producer unknown affects `head_to_base` and `head_to_head`; an unknown base consumer affects `head_to_base`; an unknown head consumer affects `base_to_head` and `head_to_head`. A head class-presence uncertainty replacing a base worker affects `base_to_head`; the symmetric base uncertainty for a new head worker and head enqueue affects `head_to_base`. The same semantic root present in both snapshots is one finding with the union in canonical order. Do not emit one warning per downstream rule. A parser syntax error in a selected file is a tool error with exit 2; uncertainty from an unselected presence-only file is JC007.
571
+ - **Unsafe example:** `ExportJob.perform_async(*args)` or `def perform(id:)`.
572
+ - **Safe example:** explicit producer arguments with a positional `def perform(id, options = {})`.
573
+ - **Remediation:** use supported explicit syntax, exclude non-production code, or accept the warning while recognizing that compatibility is unproven.
574
+ - **False positives:** dynamic `perform_async` methods may belong to a non-Sidekiq API; forwarded `perform` may be operationally safe.
575
+ - **False negatives:** metaprogrammed calls that do not expose a target method name in the AST.
576
+ - **Suppression:** exact rule+worker works only when a worker is known. Worker-less dynamic warnings are controlled by scan exclusions in v0.1.
577
+
578
+ ## 12. Finding de-duplication and precedence
579
+
580
+ Rules are evaluated in this order for ownership, not presentation:
581
+
582
+ 1. JC004 worker removal;
583
+ 2. JC005 new worker activation;
584
+ 3. JC001 old-to-new regression, absorbing the same worker/arity's head-to-head mismatch;
585
+ 4. JC003 remaining current mismatch;
586
+ 5. JC002 new-to-old rollout regression;
587
+ 6. JC006 unproven narrowing;
588
+ 7. JC007 unknown evidence.
589
+
590
+ Requirements:
591
+
592
+ - JC004 suppresses JC001 for the same missing head consumer.
593
+ - JC005 suppresses JC002 for the same absent base consumer.
594
+ - JC004 and JC005 require an `absent` result from the opposite revision's `DefinedConstantIndex`; worker-set difference alone is insufficient.
595
+ - JC001 owns both `base_to_head` and `head_to_head` for the same worker, payload arity, and head consumer contract when qualifying base and head producer evidence exists. Its `revisions`, `directions`, and proof locations are unions. JC003 MUST NOT also report that worker/arity.
596
+ - JC003 owns a head-to-head mismatch only when JC001 does not own the same worker/arity.
597
+ - JC003 suppresses JC002 for the same head call and arity.
598
+ - Identical rule/worker/arity evidence is aggregated into one finding with multiple locations. The aggregation key MUST NOT include `directions`, because direction union happens after evidence ownership.
599
+ - JC006 is not emitted when JC001 already proves a removed accepted arity for that worker.
600
+ - JC006 is not emitted when JC003 already proves, for the same worker, a current head call using an arity accepted by base but removed by head.
601
+ - JC007 is coalesced by semantic root evidence across base/head and may coexist with JC006 when the roots represent different facts. A JC004/JC005 presence uncertainty creates one JC007 for that transition; do not separately warn once per file inspected.
602
+
603
+ ### 12.1 JC007 semantic evidence fingerprint
604
+
605
+ The fingerprint excludes revision and source line/column. It is the tuple:
606
+
607
+ ```text
608
+ [
609
+ uncertainty_kind,
610
+ unknown_reason,
611
+ canonical_worker_name_or_null,
612
+ sorted_repository_relative_source_paths,
613
+ lexical_enclosing_class_module_and_method_chain,
614
+ normalized_root_expression_and_enclosing_statement,
615
+ occurrence_group_size,
616
+ occurrence_ordinal
617
+ ]
618
+ ```
619
+
620
+ `uncertainty_kind` is one of `producer_arity`, `producer_receiver`, `consumer_contract`, `worker_identity`, `class_presence`. The reason uses the fixed enum for producer, consumer, or presence uncertainty. The lexical chain records syntactic class/module segments and method name/kind, including an explicit anonymous/unsupported marker where a canonical name cannot be established. For a multi-fragment consumer uncertainty, the root is the class's aggregate contract fact: paths and normalized expressions of all relevant direct include/`perform` fragments are sorted, and all fragment locations are retained. A missing `perform` uses the recognized include fragment(s) as its root. For class-presence uncertainty, the root is the observed declaration/binding when one exists; `unverified` uses the candidate worker name plus the deterministic blocking file/budget identity.
621
+
622
+ Normalize only the syntax that causes uncertainty as an ordered sequence of Prism token kind and token bytes, omitting whitespace and comments. For producer uncertainty, use the target enqueue call; for consumer uncertainty, use the `def perform` header/parameter list, not the method body; for worker identity, use the declaration path and relevant include; for presence uncertainty, use the observed declaration/binding or a synthetic `presence_unverified` marker plus the blocking path or budget boundary. Preserve identifiers, literal bytes, punctuation, and child order. Include the smallest enclosing statement's normalized token sequence for producer calls so identical calls in different statements remain distinct. This is conservative syntax equivalence, not Ruby evaluation: a changed relevant token may leave two findings even when runtime meaning is equal. That is preferable to merging different causes. Unrelated method-body edits do not change a signature fingerprint.
623
+
624
+ Within each revision and each tuple prefix through the normalized expression, sort roots by source byte offset and assign `occurrence_ordinal` from 1. Include the group's size in the fingerprint. Thus two identical expressions in one scope are always separate findings. Cross-revision pairing is allowed only for exact fingerprint equality and one-to-one ordinal correspondence; changed multiplicity or uncertain pairing leaves separate findings. Matched roots yield one JC007 with unioned `revisions`, `directions`, and deduplicated, revision-tagged locations. The primary display location is the first sorted location. An unchanged selected blob at the same path therefore yields one warning, not one per revision.
625
+
626
+ Every finding MUST carry all locations needed to audit its proof, not only the producer locations:
627
+
628
+ | Rule | Required location roles |
629
+ | --- | --- |
630
+ | JC001 | base producer, base consumer, head consumer; head producer too when `head_to_head` is included |
631
+ | JC002 | head producer, head consumer, base consumer |
632
+ | JC003 | head producer, head consumer |
633
+ | JC004 | base worker declaration and consumer when present; head producer calls still targeting the absent name when present |
634
+ | JC005 | head worker declaration and head producer |
635
+ | JC006 | base and head consumers |
636
+ | JC007 | every matched root unsupported call, signature, worker fragment, or presence-blocking declaration; base/head supporting worker declarations when presence is unverified |
637
+
638
+ ## 13. False-positive policy
639
+
640
+ The normative policy is:
641
+
642
+ ```text
643
+ ERROR = a structural incompatibility is proven under the documented
644
+ rolling-deployment and callsite-reachability assumptions.
645
+
646
+ WARNING = risk or incomplete analysis exists, but incompatibility cannot
647
+ be fully proven from supported source evidence.
648
+ ```
649
+
650
+ For ERROR rules, “proven” means that the supported AST contains the relevant worker/call contracts and the set/range relation fails. It does not prove that a branch executes, a feature flag is enabled, or a matching job currently exists in Redis. Those are declared deployment assumptions and suppression responsibilities.
651
+
652
+ For JC004 and JC005, “proven” additionally requires completed static class-presence absence in the opposite snapshot. A present or unverified class is JC007, never an absent-class ERROR. JC005's old-process failure is conditional on that process being able to consume the new job from its queue during overlap. Queue isolation and deployment sequencing are not analyzed.
653
+
654
+ Unknown MUST never be converted to compatible. This is the product principle:
655
+
656
+ > Absence of proof is not proof of compatibility.
657
+
658
+ ## 14. CLI
659
+
660
+ ### 14.1 Commands
661
+
662
+ ```text
663
+ jobcompat check --base REF [options]
664
+ jobcompat --help
665
+ jobcompat --version
666
+ jobcompat check --help
667
+ ```
668
+
669
+ `init` is not included. Defaults are usable without configuration, and generating one small YAML file does not justify another command in v0.1.
670
+
671
+ ### 14.2 `check` options
672
+
673
+ ```text
674
+ --base REF required; base commit-ish
675
+ --head REF optional; default HEAD
676
+ --format FORMAT text (default) or json
677
+ --config PATH optional; default <git-root>/.jobcompat.yml when present
678
+ -h, --help command help
679
+ ```
680
+
681
+ No `--strict`, `--verbose`, color control, output-file option, or SARIF option exists in v0.1. Warnings always exit 0. Text output contains no ANSI color, making terminals and CI logs deterministic.
682
+
683
+ A relative `--config` path is resolved against the invocation directory. The default config path is resolved at the Git root. Missing explicitly requested config is exit 2; absent default config is valid and uses defaults.
684
+
685
+ ### 14.3 Streams
686
+
687
+ - help/version: stdout;
688
+ - completed text or JSON analysis: stdout;
689
+ - CLI usage errors before a format is established: stderr;
690
+ - text-mode tool/config/git/parser errors: stderr;
691
+ - JSON-mode tool/config/git/parser errors after option parsing: a JSON failure envelope on stdout; unexpected crash details remain on stderr without a backtrace unless a future debug mode is added.
692
+
693
+ ## 15. Text output
694
+
695
+ Text output MUST lead with the comparison and summary, then list findings in deterministic order. Example:
696
+
697
+ ```text
698
+ jobcompat 0.1.0
699
+ Comparing origin/main (a1b2c3d) -> HEAD (d4e5f6a)
700
+ Deployment model: rolling
701
+
702
+ ERROR JC002 ExportJob
703
+ New payload is not accepted by the previous worker.
704
+
705
+ Revisions: base, head
706
+ Affected directions:
707
+ HEAD producer -> base consumer
708
+ Base worker: perform(user_id) accepts 1
709
+ HEAD worker: perform(user_id, format = nil) accepts 1..2
710
+ HEAD producer: ExportJob.perform_async(user_id, "csv") emits 2
711
+
712
+ Risk: During a rolling deploy, a new application node can enqueue this
713
+ payload before all Sidekiq workers have been upgraded.
714
+
715
+ base app/jobs/export_job.rb:4 (consumer)
716
+ head app/jobs/export_job.rb:4 (consumer)
717
+ head app/services/exporter.rb:21 (producer)
718
+
719
+ Suggested migration:
720
+ 1. Deploy the optional worker argument without using it.
721
+ 2. Start enqueueing the new argument in a later release.
722
+
723
+ Summary: 1 error, 0 warnings, 0 suppressed
724
+ ```
725
+
726
+ A shared old/current payload failure renders as one JC001, not an additional JC003:
727
+
728
+ ```text
729
+ ERROR JC001 ExportJob
730
+ Base and HEAD producers emit 1 argument; the HEAD worker requires 2.
731
+ Revisions: base, head
732
+ Affected directions:
733
+ base producer -> HEAD consumer
734
+ HEAD producer -> HEAD consumer
735
+ ```
736
+
737
+ An unchanged unsupported call in both snapshots renders once:
738
+
739
+ ```text
740
+ WARNING JC007 ExportJob
741
+ Revisions: base, head
742
+ Reason: splat_arguments (producer payload arity is unknown)
743
+ Affected directions:
744
+ base producer -> HEAD consumer
745
+ HEAD producer -> base consumer
746
+ HEAD producer -> HEAD consumer
747
+ ```
748
+
749
+ JC005 risk text MUST say: `If an old Sidekiq process can consume this job during the rolling deployment, it cannot resolve the new worker class.` It MUST NOT say production execution will inevitably fail. JC006 text MUST state that no repository producer callsite found does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists. These explanations appear inside the relevant finding, not as a warning on every otherwise passing worker.
750
+
751
+ No findings:
752
+
753
+ ```text
754
+ jobcompat 0.1.0
755
+ Comparing origin/main (a1b2c3d) -> HEAD (d4e5f6a)
756
+ Deployment model: rolling
757
+
758
+ PASS: no compatibility errors or warnings found.
759
+ Summary: 0 errors, 0 warnings, 0 suppressed
760
+ ```
761
+
762
+ Warnings without errors use `PASS WITH WARNINGS` and exit 0.
763
+
764
+ When suppressions match, text output adds a compact block before the summary, one line per matched `(rule, worker)` in rule/worker order:
765
+
766
+ ```text
767
+ Suppressed findings:
768
+ JC005 ExperimentalJob (1): Enqueue activation occurs only after the worker rollout completes
769
+
770
+ Summary: 0 errors, 0 warnings, 1 suppressed
771
+ ```
772
+
773
+ ## 16. JSON output schema v1
774
+
775
+ ### 16.1 Evolution policy
776
+
777
+ - `schema_version` is integer `1` for v0.1.
778
+ - Removing a field, changing a field's type/meaning, or changing an enum incompatibly requires a schema-version increment.
779
+ - New optional fields may be added within schema version 1. Consumers MUST ignore unknown fields.
780
+ - All documented fields are always present; unavailable values use `null`, not omission, except future additive fields.
781
+ - JSON is UTF-8, pretty-printed with two-space indentation, contains unescaped Unicode where the JSON library permits it, and ends with exactly one newline.
782
+
783
+ ### 16.2 Completed example
784
+
785
+ ```json
786
+ {
787
+ "schema_version": 1,
788
+ "tool": { "name": "jobcompat", "version": "0.1.0" },
789
+ "status": "completed",
790
+ "comparison": {
791
+ "deployment_model": "rolling",
792
+ "base": { "ref": "origin/main", "sha": "a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4a1b2c3d4" },
793
+ "head": { "ref": "HEAD", "sha": "d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7d4e5f6a7" }
794
+ },
795
+ "configuration": { "path": ".jobcompat.yml" },
796
+ "findings": [
797
+ {
798
+ "rule_id": "JC002",
799
+ "title": "New payload rejected by old worker",
800
+ "severity": "error",
801
+ "worker": "ExportJob",
802
+ "revisions": ["base", "head"],
803
+ "directions": ["head_to_base"],
804
+ "unknown_reason": null,
805
+ "message": "HEAD emits 2 arguments, but the base worker accepts 1.",
806
+ "risk": "A new producer can enqueue work that an old worker cannot execute during a rolling deploy.",
807
+ "remediation": [
808
+ "Deploy the optional worker argument first.",
809
+ "Start enqueueing the new argument in a later release."
810
+ ],
811
+ "payload_arity": 2,
812
+ "locations": [
813
+ { "revision": "base", "path": "app/jobs/export_job.rb", "line": 4, "column": 3, "role": "consumer" },
814
+ { "revision": "head", "path": "app/jobs/export_job.rb", "line": 4, "column": 3, "role": "consumer" },
815
+ { "revision": "head", "path": "app/services/exporter.rb", "line": 21, "column": 5, "role": "producer" }
816
+ ]
817
+ }
818
+ ],
819
+ "suppressions": [],
820
+ "workers": [
821
+ {
822
+ "name": "ExportJob",
823
+ "base_presence": "recognized_worker",
824
+ "head_presence": "recognized_worker",
825
+ "base_contract": { "status": "known", "min_arity": 1, "max_arity": 1, "variadic": false, "signature_kind": "positional", "signature": "perform(user_id)", "unknown_reason": null },
826
+ "head_contract": { "status": "known", "min_arity": 1, "max_arity": 2, "variadic": false, "signature_kind": "positional", "signature": "perform(user_id, format = nil)", "unknown_reason": null },
827
+ "producer_arities": {
828
+ "base": [1],
829
+ "head": [1, 2],
830
+ "base_unknown_calls": 0,
831
+ "head_unknown_calls": 0
832
+ },
833
+ "compatibility": {
834
+ "base_to_base": "pass",
835
+ "base_to_head": "pass",
836
+ "head_to_base": "fail",
837
+ "head_to_head": "pass"
838
+ }
839
+ }
840
+ ],
841
+ "diagnostics": [],
842
+ "summary": {
843
+ "errors": 1,
844
+ "warnings": 0,
845
+ "suppressed": 0,
846
+ "files_scanned": { "base": 42, "head": 43 },
847
+ "workers": { "base": 8, "head": 8 },
848
+ "enqueue_calls": { "base": 21, "head": 22, "unknown": 0 }
849
+ }
850
+ }
851
+ ```
852
+
853
+ The `directions` array also lets one JC001 carry `base_to_head` and `head_to_head`. For an unchanged `ExportJob.perform_async(*args)` root in both snapshots, the completed finding has `"rule_id": "JC007"`, `"revisions": ["base", "head"]`, `"directions": ["base_to_head", "head_to_base", "head_to_head"]`, `"unknown_reason": "splat_arguments"`, and both revision-tagged locations; it counts as one warning.
854
+
855
+ ### 16.3 Failure example
856
+
857
+ ```json
858
+ {
859
+ "schema_version": 1,
860
+ "tool": { "name": "jobcompat", "version": "0.1.0" },
861
+ "status": "failed",
862
+ "comparison": {
863
+ "deployment_model": "rolling",
864
+ "base": { "ref": "missing-ref", "sha": null },
865
+ "head": { "ref": "HEAD", "sha": null }
866
+ },
867
+ "configuration": { "path": null },
868
+ "findings": [],
869
+ "suppressions": [],
870
+ "workers": [],
871
+ "diagnostics": [
872
+ {
873
+ "category": "git_error",
874
+ "message": "Base ref 'missing-ref' does not resolve to a commit.",
875
+ "location": null
876
+ }
877
+ ],
878
+ "summary": null
879
+ }
880
+ ```
881
+
882
+ ### 16.4 Field definitions
883
+
884
+ | Field | Type | Meaning |
885
+ | --- | --- | --- |
886
+ | `schema_version` | integer | output contract version |
887
+ | `tool` | object | stable `name` (`jobcompat`) and semantic `version` strings |
888
+ | `status` | `completed` or `failed` | whether analysis completed |
889
+ | `comparison.deployment_model` | `rolling` | fixed v0.1 deployment model |
890
+ | `comparison.base/head.ref` | string | user-requested ref |
891
+ | `comparison.base/head.sha` | string or null | resolved full commit SHA; null only when resolution did not complete |
892
+ | `configuration.path` | string or null | loaded config display path; null when defaults were used or config loading did not complete |
893
+ | `findings` | array | unsuppressed rule findings; empty on failed analysis |
894
+ | `findings[].rule_id` | `JC001` through `JC007` | stable rule identifier |
895
+ | `findings[].title` | string | stable rule title from the catalog |
896
+ | `findings[].severity` | `error` or `warning` | severity before suppression |
897
+ | `findings[].worker` | string or null | canonical name; null for unattributed dynamic or unsupported-path evidence |
898
+ | `findings[].revisions` | non-empty array | participating proof revisions in `base`, `head` order; for JC007, snapshots containing the matched root evidence |
899
+ | `findings[].directions` | non-empty array | unique subset in `base_to_head`, `head_to_base`, `head_to_head` order; JC001 can have two |
900
+ | `findings[].unknown_reason` | string or null | fixed producer/consumer/presence reason for JC007, null for other rules |
901
+ | `findings[].message` | string | concise fact-specific explanation |
902
+ | `findings[].risk` | string | deployment consequence |
903
+ | `findings[].remediation` | non-empty array of strings | ordered corrective steps |
904
+ | `findings[].payload_arity` | non-negative integer or null | known evidence arity, otherwise null |
905
+ | `findings[].locations` | non-empty array | proof locations required by the rule table above |
906
+ | finding location | object | `revision`, repository-relative `path`, 1-based `line`, 1-based byte `column`, and `role` |
907
+ | `suppressions` | array | matched targeted suppressions, empty when none or on failed analysis |
908
+ | `suppressions[].rule_id/worker/reason` | strings | exact configured suppression identity and mandatory rationale |
909
+ | `suppressions[].finding_count` | positive integer | number of findings omitted by that suppression |
910
+ | `workers` | array | one entry for each canonical name in the base/head worker union, sorted by name |
911
+ | `workers[].name` | string | canonical worker name without leading `::` |
912
+ | `workers[].base_presence/head_presence` | presence enum | `recognized_worker`, `defined_unrecognized`, `outside_scan_scope`, `absent`, `unverified`, or `not_checked`; the last means no absence-dependent rule needed a query |
913
+ | `workers[].base_contract/head_contract` | contract object or null | null means no recognized worker contract in that revision; use presence status to distinguish proven absence from unrecognized/out-of-scope/unverified class |
914
+ | contract `status` | `known` or `unknown` | whether a positional interval was extracted |
915
+ | contract `min_arity/max_arity/variadic` | integer/null/boolean or nulls | populated for `known`; `max_arity: null` plus `variadic: true` means unbounded; all three null for `unknown` |
916
+ | contract `signature_kind` | `positional`, `forwarding`, or null | null for unknown |
917
+ | contract `signature` | string or null | concise source signature; null when unavailable |
918
+ | contract `unknown_reason` | string or null | reason enum for unknown; null for known |
919
+ | `producer_arities.base/head` | arrays of integers | sorted unique known payload arities |
920
+ | `producer_arities.base_unknown_calls/head_unknown_calls` | non-negative integers | visible calls attributable to this worker whose arity is unknown |
921
+ | `compatibility.*` | matrix status | one of `pass`, `fail`, `unknown`, `not_applicable` |
922
+ | `diagnostics` | array | empty on completed analysis; one or more tool diagnostics on failure |
923
+ | `diagnostics[].category` | diagnostic enum | `config_error`, `git_error`, `parse_error`, or `internal_error` |
924
+ | `diagnostics[].message` | string | concise sanitized failure explanation |
925
+ | `diagnostics[].location` | object or null | optional `revision`, `path`, `line`, and 1-based byte `column`; it has no finding role |
926
+ | `summary` | object or null | completed counts, null on failed analysis |
927
+ | `summary.errors/warnings/suppressed` | non-negative integers | unsuppressed error/warning counts and matched suppressed-finding count |
928
+ | `summary.files_scanned.base/head` | non-negative integers | selected blobs parsed per revision |
929
+ | `summary.workers.base/head` | non-negative integers | recognized canonical workers per revision, including unknown contracts |
930
+ | `summary.enqueue_calls.base/head/unknown` | non-negative integers | visible target enqueue calls by revision; `unknown` is the cross-revision count with unknown receiver or arity |
931
+
932
+ `diagnostics[].category` enum: `config_error`, `git_error`, `parse_error`, `internal_error`.
933
+
934
+ Location `role` enum: `consumer`, `producer`, `worker_declaration`, `unknown_call`. Location `revision` enum: `base`, `head`.
935
+
936
+ Finding `unknown_reason` uses the producer values in §9.4, consumer values in §8.3, or the presence values `worker_not_recognized`, `outside_analysis_scope`, `presence_unverified`. The latter correspond respectively to `defined_unrecognized`, `outside_scan_scope`, and `unverified`. The public `revisions` and `directions` arrays have the fixed orders from §10.1; no singular `direction` field exists in schema v1.
937
+
938
+ All arrays and objects are serialized in the key order shown. Although JSON consumers cannot rely on object-key order semantically, exact-output tests enforce deterministic bytes.
939
+
940
+ For a contract with an unsupported signature, the JSON shape is:
941
+
942
+ ```json
943
+ {
944
+ "status": "unknown",
945
+ "min_arity": null,
946
+ "max_arity": null,
947
+ "variadic": null,
948
+ "signature_kind": null,
949
+ "signature": "perform(id:)",
950
+ "unknown_reason": "keyword_parameters"
951
+ }
952
+ ```
953
+
954
+ `status: "completed"` covers both exit 0 and exit 1; a compatibility finding is a completed analysis, not a tool failure. On `completed`, `diagnostics` is empty and `summary` is non-null. On `failed`, `findings`, `suppressions`, and `workers` are empty, `diagnostics` is non-empty, and `summary` is null.
955
+
956
+ ## 17. Exit codes
957
+
958
+ | Code | Meaning |
959
+ | ---: | --- |
960
+ | 0 | analysis completed and no unsuppressed error findings exist; warnings may exist |
961
+ | 1 | analysis completed with at least one unsuppressed error finding |
962
+ | 2 | CLI, config, Git, parser, I/O, or internal tool failure prevented a trustworthy complete analysis |
963
+
964
+ Suppressed errors do not affect the exit code. A parse error in any selected Ruby file is exit 2, not JC007 and not a partial success.
965
+
966
+ `--strict` is deliberately omitted. A fixed warning policy keeps CI semantics simple for v0.1.
967
+
968
+ ## 18. Configuration
969
+
970
+ ### 18.1 File
971
+
972
+ Default file: `.jobcompat.yml` at the Git root. Minimal schema:
973
+
974
+ ```yaml
975
+ version: 1
976
+
977
+ scan:
978
+ include:
979
+ - "**/*.rb"
980
+ exclude:
981
+ - "vendor/**"
982
+ - "tmp/**"
983
+ - "log/**"
984
+ - "coverage/**"
985
+ - ".bundle/**"
986
+ - "test/**"
987
+ - "spec/**"
988
+ - "features/**"
989
+ - "examples/**"
990
+
991
+ ignore:
992
+ - rule: JC005
993
+ worker: ExperimentalJob
994
+ reason: "Enqueue activation occurs only after the worker rollout completes"
995
+ ```
996
+
997
+ ### 18.2 Validation
998
+
999
+ - top-level keys allowed: `version`, `scan`, `ignore`;
1000
+ - `version` is required when a config exists and MUST equal integer `1`;
1001
+ - `scan` keys allowed: `include`, `exclude`;
1002
+ - omitted `scan` uses both default arrays; omitting only `include` or `exclude` preserves the default for that key;
1003
+ - `include` is a non-empty array of non-empty glob strings;
1004
+ - `exclude` is an array of non-empty glob strings and MAY be empty to clear all default exclusions;
1005
+ - glob strings are repository-relative and MUST NOT start with `/`, contain NUL, or contain a `..` path segment;
1006
+ - omitted `ignore` means an empty array; an explicit empty array is valid;
1007
+ - `ignore` is an array of objects with exactly `rule`, `worker`, `reason`;
1008
+ - rule is one of JC001–JC007;
1009
+ - worker is a non-empty exact canonical worker name without leading `::`;
1010
+ - reason is a non-blank string;
1011
+ - duplicate `(rule, worker)` entries are invalid;
1012
+ - unknown keys at any level are invalid;
1013
+ - YAML aliases and arbitrary object deserialization are forbidden;
1014
+ - invalid config is exit 2.
1015
+
1016
+ The implementation MUST use `Psych.safe_load` with no permitted classes or symbols and aliases disabled.
1017
+
1018
+ ### 18.3 Suppression behavior
1019
+
1020
+ Suppressions are applied after findings are constructed and before formatting/exit-code calculation. A suppression matches exact `rule_id` and exact canonical `worker`. It suppresses all findings for that pair, including multiple arities/callsites.
1021
+
1022
+ Suppressed findings are omitted from `findings`, but each matched `(rule, worker)` is represented once in the text suppression block and JSON `suppressions` array with its configured reason and `finding_count`. The summary `suppressed` value is the sum of those counts. Unmatched config entries are not rendered.
1023
+
1024
+ There is no global rule disable, wildcard worker, severity customization, expiry, or inline source comment in v0.1. Inline comments are rejected because they couple application source to a young tool, complicate AST/comment association, and encourage broad local silence. The YAML entry gives reviewers one central reasoned exception list.
1025
+
1026
+ Unused suppression entries do not fail v0.1 and do not warn; stale-ignore reporting is a roadmap option.
1027
+
1028
+ ## 19. Determinism
1029
+
1030
+ The same Git object database, refs resolved to the same SHAs, config bytes, jobcompat version, Prism major/minor, and Ruby platform MUST produce byte-identical JSON.
1031
+
1032
+ Sort order:
1033
+
1034
+ 1. findings: severity (`error`, then `warning`), rule ID, worker (null last), payload arity (null last), primary path, line, directions, revisions, unknown reason;
1035
+ 2. finding locations: revision (`base`, then `head`), path, line, column, role;
1036
+ 3. suppressions: rule ID, worker;
1037
+ 4. workers: canonical name;
1038
+ 5. producer arity arrays: numeric ascending and unique;
1039
+ 6. diagnostics: category, location path/line, message.
1040
+
1041
+ Text uses the same finding order.
1042
+
1043
+ ## 20. Failure handling and safety
1044
+
1045
+ - Source code is read only from local Git objects.
1046
+ - Source is never uploaded or sent to an external API.
1047
+ - Redis credentials are neither required nor read.
1048
+ - Application source is never required, evaluated, or executed.
1049
+ - Git commands use argv arrays through `Open3`; no shell string or `eval` is allowed.
1050
+ - User refs are resolved once with `--end-of-options`; resolved SHAs are used afterward.
1051
+ - Raw blobs are read without textconv, filters, checkout, or hooks.
1052
+ - Invalid source encoding or Prism parse error is a located parse diagnostic and exit 2.
1053
+ - Selected files are visited in normalized path order. Parsing continues across selected files only to collect all Prism errors; if any exist, they are sorted by revision/path/line/column, compatibility evaluation is skipped, and JSON uses one failed envelope.
1054
+ - Expected operational errors MUST not print Ruby backtraces.
1055
+
1056
+ Boundary evaluation order is deterministic: parse CLI options, locate Git root, load/validate config, resolve base, resolve head, read/parse snapshots, then evaluate compatibility. Except for parse-error aggregation, the first failed boundary stops the command. Partial ref resolutions already obtained are retained in the failure envelope; SHAs not yet resolved are null.
1057
+
1058
+ ## 21. Limitations for README
1059
+
1060
+ The README MUST state prominently:
1061
+
1062
+ - v0.1 checks positional arity only, not value/type/Hash schema compatibility;
1063
+ - native Sidekiq only; ActiveJob is not supported;
1064
+ - only direct `include Sidekiq::Job`/`Worker` discovery;
1065
+ - only documented direct enqueue syntax;
1066
+ - no live queue knowledge, so queue presence/absence is not proven; absent producer callsites never establish an empty queue;
1067
+ - JC004 requires a completed static absence proof across tracked `.rb` source; moved/excluded or unrecognized class declarations warn instead;
1068
+ - JC005 is an ERROR for structural old-fleet inability under the documented rolling model, conditional on old Sidekiq processes being able to consume the queue;
1069
+ - dynamic/metaprogrammed behavior may warn or be missed;
1070
+ - feature flags are not evaluated;
1071
+ - working-tree changes are ignored because refs resolve to commits;
1072
+ - errors rely on the rolling-deploy assumption that discovered production callsites may execute;
1073
+ - scan defaults exclude tests/specs/examples and can be configured.
1074
+
1075
+ ## 22. README v0.1 outline
1076
+
1077
+ The implementation session MUST create README sections in this order:
1078
+
1079
+ 1. `# jobcompat`
1080
+ 2. one-line pitch
1081
+ 3. five-second diff and error, before prose:
1082
+ ```diff
1083
+ -def perform(user_id)
1084
+ +def perform(user_id, format)
1085
+ ```
1086
+ `ERROR: existing queued jobs may fail after deployment`
1087
+ 4. positioning question: “Your API has a schema. Your database has migrations. What protects your queued jobs?”
1088
+ 5. Problem
1089
+ 6. Why this happens, with persisted `class` + `args`
1090
+ 7. Installation
1091
+ 8. Quick Start (`jobcompat check --base origin/main`)
1092
+ 9. Example output
1093
+ 10. How it works
1094
+ 11. Compatibility directions A/B/C
1095
+ 12. Rules JC001–JC007 summary table
1096
+ 13. Configuration and targeted suppression
1097
+ 14. CI usage, including exit codes
1098
+ 15. Supported Sidekiq patterns
1099
+ 16. Limitations / v0.1 non-goals
1100
+ 17. Why not just tests / Sorbet / Sidekiq strict args?
1101
+ 18. Deployment assumptions and staged migration example
1102
+ 19. Security and offline-friendly characteristics
1103
+ 20. Roadmap: additional Sidekiq producer APIs, SARIF, then other frameworks only after v0.1 evidence
1104
+ 21. Contributing
1105
+ 22. License
1106
+
1107
+ The README License section links to `LICENSE` and states MIT. Repository/homepage metadata that requires a future GitHub owner or URL MUST be omitted rather than invented until that external fact exists.
1108
+
1109
+ The README MUST not advertise ActiveJob/BullMQ/Celery support as if committed. They may appear only in roadmap language.
1110
+
1111
+ ## 23. Acceptance criteria
1112
+
1113
+ v0.1 is specification-complete when an implementation satisfies all of:
1114
+
1115
+ 1. no Git working-tree/index mutation;
1116
+ 2. direct Sidekiq job discovery for all three namespace forms and legacy Worker;
1117
+ 3. exact interval arity for zero, required, optional, rest, post, forwarding, and parameter rename;
1118
+ 4. known producer arity for async, scheduled, and `.set(...).perform_async` calls;
1119
+ 5. Hash/Array each count as one argument;
1120
+ 6. splats and unsupported signatures never pass silently;
1121
+ 7. A/B/C directions are separately represented;
1122
+ 8. optional consumer expansion alone is safe;
1123
+ 9. starting to produce the new optional argument yields JC002 against base;
1124
+ 10. a worker class proven absent from HEAD tracked `.rb` declarations yields JC004, while a present or unverified class yields JC007 instead;
1125
+ 11. new worker plus head enqueue yields JC005;
1126
+ 12. narrowing without base producer evidence yields JC006 only;
1127
+ 13. a head-only mismatch yields JC003; when the same worker/arity also qualifies for JC001, one JC001 has both directions and no JC003;
1128
+ 14. config is strict and safe-loaded;
1129
+ 15. exact rule+worker suppression works and requires a reason;
1130
+ 16. JSON conforms to schema version 1 and is deterministic;
1131
+ 17. exits 0/1/2 exactly as specified;
1132
+ 18. invalid selected Ruby source causes exit 2 with location;
1133
+ 19. tests create local temporary Git repositories and need no network;
1134
+ 20. README communicates value in its first screen and accurately lists limitations;
1135
+ 21. identical semantic JC007 roots across snapshots aggregate to one finding with both revisions, while distinct roots remain separate;
1136
+ 22. a file rename or movement of selected reopened fragments with unchanged canonical name and contract yields no finding;
1137
+ 23. a class moved outside normal scan scope but present in tracked `.rb` source does not yield JC004;
1138
+ 24. no repository producer with a narrowed contract yields JC006 without claiming queue emptiness.
1139
+
1140
+ ## 24. v0.1 specification freeze review
1141
+
1142
+ The 2026-09-23 freeze review checked the normative rules against `docs/architecture.md` and `docs/implementation-plan.md`:
1143
+
1144
+ | Gate | Result | Contract |
1145
+ | --- | --- | --- |
1146
+ | 1. Same JC007 evidence across base/head | pass | revision-free fingerprint pairs one-to-one; `revisions` and directions are unioned |
1147
+ | 2. JC001/JC003 precedence | pass | qualifying JC001 absorbs the same worker/arity's head-to-head failure; JC003 handles remaining head mismatches |
1148
+ | 3. Multiple directions per finding | pass | non-empty `directions` array with fixed A/B/C order |
1149
+ | 4. Class remains but worker recognition fails | pass | presence is `defined_unrecognized`; JC007, never JC004 |
1150
+ | 5. Class moved outside normal scan scope | pass | presence-only tracked-`.rb` pass yields `outside_scan_scope`; JC007, never JC004 |
1151
+ | 6. Actual tracked-`.rb` class deletion | pass | complete `absent` proof yields JC004 |
1152
+ | 7. JC005 conditional risk | pass | proven base absence plus head enqueue; old-fleet failure is conditional on queue consumption |
1153
+ | 8. Producer absence | pass | JC006 explains why no callsite does not establish an empty queue |
1154
+ | 9. File rename only | pass | canonical class/contract unchanged means no finding |
1155
+ | 10. Implementation test coverage | pass | the cross-revision matrix covers dedup, presence safety, class/file moves, reopening, and producer absence |
1156
+ | 11. ERROR proof standard | pass | JC001–JC003 use known contracts/calls; JC004/JC005 additionally require static absence proof; other uncertainty is WARNING |
1157
+ | 12. Normative completeness | pass | no open placeholder or undecided v0.1 behavior remains in this specification |