jobcompat 0.2.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47c2a0eafb9ae4ced87a7e13389e1ebd043a281d5b711f79bf668179592fea33
4
- data.tar.gz: a2447a5027360b6bf7843c6c7766b6b8af1977a1e8308700d298e956b428d87e
3
+ metadata.gz: 6b1df789562ba30ff3474a112bd44e602d68b833b7d34c92b936f8e508e20484
4
+ data.tar.gz: ba09c1c7f2ce1f22d44cf690559e395584be8492c5abbbad85d492281b6849cd
5
5
  SHA512:
6
- metadata.gz: f6d44307647aed53748ca8b429ecf7469bb79fac73644a9f559a4b7c89c97bb6c52d60da03edd604b787e4959366e775579fa7a140341f1153902eefda90f528
7
- data.tar.gz: e338de388c16b907edcd05377460bbb741bc268033821a952e0e1ce65e49cc46bbe45348ca30414e65faddb3d9c7d179e15c5538c92373a07ae76e294c787f31
6
+ metadata.gz: c4e50e2e13f1bc1d3a3e667b63a3865bd01260f8b9bd462db724c0aa44641fd555f191a3059548df2ca408e13a081d3daeb993e4a805a7b9617d79034a36643b
7
+ data.tar.gz: aff92fbf9b66f5320958497d79a07fbf0defe4b56f1da444fddf69340478f72677e2cad601c5e9951d4de5e76a27f71095ba741e2c0d437be42a8eec75f9413a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-10-05
4
+
5
+ - Recognize static Sidekiq compatibility aliases, including finite chains and supported namespaces, to preserve old serialized identities across worker renames.
6
+ - Normalize Class-object producers through aliases to the terminal Class name while preserving exact Client String identities.
7
+ - Report relevant unresolved, cyclic, conflicting, or unsupported alias bindings conservatively as JC007.
8
+ - Emit JSON schema 3 with effective serialized-identity worker results, alias metadata, and resolved_alias presence; direct worker summary counts and config schema 1 remain unchanged.
9
+
3
10
  ## 0.2.1 - 2026-10-04
4
11
 
5
12
  Canonical worker-name consistency:
data/README.md CHANGED
@@ -54,7 +54,7 @@ ERROR JC001 ExportJob
54
54
  Risk: Jobs queued by the base revision may fail after deployment.
55
55
  ```
56
56
 
57
- The second direction appears only when HEAD also has a one-argument producer. Output includes source locations, suggested migration steps, resolved commit SHAs, and a summary. JSON output uses `schema_version: 2` and includes per-worker compatibility matrices.
57
+ The second direction appears only when HEAD also has a one-argument producer. Output includes source locations, suggested migration steps, resolved commit SHAs, and a summary. JSON output uses `schema_version: 3` and includes per-serialized-identity compatibility matrices and alias provenance.
58
58
 
59
59
  ## How it works
60
60
 
@@ -140,9 +140,36 @@ jobcompat sees payload arities 1 and 2. A dynamic bulk row is retained as `dynam
140
140
 
141
141
  Positional `perform` parameters may be required, optional, rest, post-rest, or forwarding. Keyword parameters make the worker contract unknown in v0.2.
142
142
 
143
- ## Limitations and v0.2 non-goals
143
+ ### Worker rename and static compatibility aliases
144
144
 
145
- v0.2 checks **positional arity only**. It does not check value types, Hash internals, keyword compatibility, or JSON serialization. It supports native Sidekiq, not ActiveJob. Dynamic bulk collections are warning/unknown, Client payload variables are not followed, and custom wrappers are not analyzed. ActiveJob, arbitrary Client instances, Hash internal schemas, aliases, inheritance, concerns, arbitrary metaprogramming, feature flags, and general Ruby class/module or superclass conflict analysis remain outside scope.
145
+ jobcompat recognizes plain static constant aliases to directly recognized jobs, including finite chains and supported namespaces:
146
+
147
+ ```ruby
148
+ class MyNewJob
149
+ include Sidekiq::Job
150
+ def perform(id); end
151
+ end
152
+
153
+ MyOldJob = MyNewJob
154
+ ```
155
+
156
+ Old queued `"MyOldJob"` payloads can resolve through the alias to the new worker contract, so this rename does not produce JC004. Arity incompatibilities still produce the normal findings under the persisted name.
157
+
158
+ `MyOldJob.perform_async(1)` uses the aliased Class object: Sidekiq serializes its canonical name **MyNewJob**, not the source alias token. This also applies to scheduling, bulk, Setter calls, and Client constant payloads. During a rolling deployment, activating that producer can still cause JC005 MyNewJob if old processes cannot resolve the new identity. A compatibility alias alone does not make immediate new-name producer activation safe.
159
+
160
+ By contrast, an explicit Client String keeps its exact identity:
161
+
162
+ ```ruby
163
+ Sidekiq::Client.push("class" => "MyOldJob", "args" => [1])
164
+ ```
165
+
166
+ jobcompat models that producer as MyOldJob, then follows the alias on the consumer side. This describes identity behavior; Client Strings also have different job-option/default semantics.
167
+
168
+ Only unconditional plain constant assignments are trusted. Cycles, conflicting or repeated bindings, dynamic assignments, and conditional aliases warn when relevant. Excluded aliases can block an absence proof but are not trusted as compatibility paths. There is no const_set/autoload/const_missing support, conditional alias proof, runtime load-order proof, or heuristic rename detection. Recognition assumes successful application boot, just as direct worker recognition does.
169
+
170
+ ## Limitations and non-goals
171
+
172
+ jobcompat checks **positional arity only**. It does not check value types, Hash internals, keyword compatibility, or JSON serialization. It supports native Sidekiq, not ActiveJob. Dynamic bulk collections are warning/unknown, Client payload variables are not followed, and custom wrappers are not analyzed. ActiveJob, arbitrary Client instances, Hash internal schemas, general Ruby aliases beyond the static subset above, inheritance, concerns, arbitrary metaprogramming, feature flags, and general Ruby class/module or superclass conflict analysis remain outside scope.
146
173
 
147
174
  No repository producer callsite does **not** prove an empty queue: queued, scheduled, retried, historical, and external jobs may exist. JC004 requires a completed static absence proof across tracked `.rb` source. An excluded or unrecognized declaration warns. JC005 is a structural rolling-deploy error conditional on an old Sidekiq process being able to consume the new job's queue. Queue isolation and feature flags are not analyzed. Dynamic source may warn or be missed. Working-tree edits are ignored.
148
175
 
@@ -171,7 +198,7 @@ Possible future work includes SARIF output and additional evidence sources. Othe
171
198
 
172
199
  ## Contributing
173
200
 
174
- Run `bundle exec rake test` and `gem build jobcompat.gemspec` with Ruby 3.3 or newer. Tests create temporary Git repositories and need no Redis or Sidekiq server. The [v0.1 specification](docs/spec-v0.1.md) remains the baseline; [the normative v0.2 delta](docs/spec-v0.2.md) defines the added producer coverage, and [the v0.2.1 delta](docs/spec-v0.2.1.md) defines shared canonical worker-name validation.
201
+ Run `bundle exec rake test` and `gem build jobcompat.gemspec` with Ruby 3.3 or newer. Tests create temporary Git repositories and need no Redis or Sidekiq server. The [v0.1 specification](docs/spec-v0.1.md) remains the baseline; [the normative v0.2 delta](docs/spec-v0.2.md) defines the added producer coverage, [the v0.2.1 delta](docs/spec-v0.2.1.md) defines shared canonical worker-name validation, and [the v0.3 delta](docs/spec-v0.3.md) defines static compatibility aliases and serialized identity resolution.
175
202
 
176
203
  ## License
177
204
 
data/docs/architecture.md CHANGED
@@ -1,7 +1,13 @@
1
- # jobcompat v0.2 architecture
1
+ # jobcompat v0.3.0 architecture
2
2
 
3
- Status: implemented design for `0.2.0`
4
- Date: 2026-09-28
3
+ Status: implemented design for `0.3.0`
4
+ Date: 2026-10-05
5
+
6
+ This document describes the current v0.3.0 architecture, extending the
7
+ historical v0.1 and v0.2 design. References to those releases record inherited
8
+ design decisions and additions. The current JSON output schema is 3;
9
+ schema 2 describes historical v0.2 behavior. `docs/spec-v0.3.md` defines the
10
+ normative v0.3.0 contract.
5
11
 
6
12
  ## 1. Architecture principles
7
13
 
@@ -214,7 +220,7 @@ The engine MUST be pure with respect to filesystem, Git, environment, clock, and
214
220
  Formatters receive a completed or failed result envelope. They MUST NOT recalculate compatibility or suppression. They render already matched suppression audit records without reconstructing omitted findings.
215
221
 
216
222
  - `Formatter::Text` renders concise human remediation.
217
- - `Formatter::Json` constructs schema-version-2 primitive Hash/Array data, preserving the v1 shape while extending producer `unknown_reason` values. It includes each finding's `revisions`, `directions`, and `unknown_reason` and each worker's base/head presence status, calls `JSON.pretty_generate`, and appends exactly one newline.
223
+ - `Formatter::Json` constructs schema-version-3 primitive Hash/Array data, retaining the historical v0.2 fields and adding alias metadata and `resolved_alias` presence. It includes each finding's `revisions`, `directions`, and `unknown_reason` and each worker's base/head presence status, calls `JSON.pretty_generate`, and appends exactly one newline.
218
224
 
219
225
  Both consume already sorted results.
220
226
 
@@ -364,12 +370,46 @@ UnresolvedEnqueueCall
364
370
  unknown_reason Symbol | nil
365
371
  ```
366
372
 
367
- The reason values map one-to-one to the frozen baseline strings in `docs/spec-v0.1.md` plus the schema-v2 producer additions in `docs/spec-v0.2.md`; visitors do not invent free-form reason text.
373
+ The reason values retain the frozen baseline strings in `docs/spec-v0.1.md` and the historical v0.2/schema-v2 producer additions in `docs/spec-v0.2.md`, with current alias additions defined in `docs/spec-v0.3.md`; visitors do not invent free-form reason text.
368
374
 
369
375
  Only after both revisions' worker names are known does the engine resolve these facts. This avoids ordering dependence between files and enables head calls to removed base workers.
370
376
 
371
377
  ## 7. Internal models
372
378
 
379
+ ### v0.3.0 serialized identity resolution
380
+
381
+ The existing Analyzer Prism pass extracts direct worker facts together with
382
+ selected-source ConstantBinding and AliasCandidate facts. AliasResolver
383
+ builds a deterministic per-revision graph using the same constant-path
384
+ and lexical namespace rules, with iterative cycle detection and strict
385
+ binding uniqueness. DefinedConstantIndex remains separate presence proof;
386
+ its excluded-file facts never become trusted alias edges.
387
+ Selected ambiguous paths retain separate leaf-name binding blockers; the
388
+ resolver checks them at alias edges and direct terminals without evaluating
389
+ dynamic receivers. Unrecognized terminal declarations remain diagnostic
390
+ proof locations.
391
+
392
+ RevisionSnapshot retains direct `workers` for summary counts and adds
393
+ `aliases`, effective consumer contracts by serialized identity, and presence
394
+ helpers. A resolved alias carries terminal contract and every edge's proof
395
+ locations; an unknown binding masks a conflicting direct consumer.
396
+ The snapshot also retains selected binding facts so alias-related producer
397
+ lookup stops at nearer non-worker class/module declarations.
398
+
399
+ Engine normalizes Class-object calls through their own revision's alias
400
+ graph to terminal identities, preserving known identity even with unknown
401
+ arity. Exact Client Strings retain their identity. Diagnostic attribution
402
+ is separate from known serialized producer identity, so an unknown alias
403
+ cannot drive JC005.
404
+ Class-object alias calls carry terminal declaration/include/perform proof.
405
+ Opposite revision names cannot override local bindings during alias lookup;
406
+ the existing non-alias cross-revision attribution is preserved.
407
+ The bounded comparison set includes historical BASE aliases and relevant
408
+ producer/binding identities; dormant HEAD aliases do
409
+ not automatically add warnings. Existing rules consume effective contracts.
410
+ Schema 3 exposes base_alias/head_alias and resolved_alias presence while
411
+ summary.workers still counts direct worker facts.
412
+
373
413
  Models SHOULD be immutable `Data.define` values on Ruby 3.3. Constructors validate invariants at the boundary; rule methods do not repeatedly revalidate them.
374
414
 
375
415
  ### 7.1 `SourceLocation`
@@ -0,0 +1,32 @@
1
+ # v0.3.0 implementation plan
2
+
3
+ Implement the normative delta in spec-v0.3.md in this order.
4
+
5
+ 1. Extend Analyzer's existing Prism pass with immutable AliasCandidate and
6
+ ConstantBinding facts. Propagate a narrow direct-statement context for
7
+ alias proof, while retaining baseline worker/producer discovery. Keep
8
+ DefinedConstantIndex responsible only for conservative presence.
9
+ 2. Build AliasResolver from selected binding inventory and direct workers.
10
+ Reuse one constant candidate helper. Iterate each chain with cycle
11
+ detection; reject duplicate/conflicting bindings and unsupported edges.
12
+ Retain deterministic chain, locations, and revision-free fingerprints.
13
+ 3. Extend RevisionSnapshot with aliases and effective consumer/presence
14
+ helpers. Preserve workers as direct facts and summary counts. Derive
15
+ alias contracts from terminal contracts with complete proof locations.
16
+ 4. Resolve producers using snapshot-local alias semantics. Keep identity
17
+ certainty separate from arity certainty and diagnostic attribution.
18
+ Preserve exact Client Strings and baseline non-alias attribution.
19
+ 5. Feed relevant serialized identities and effective consumers into Engine.
20
+ Keep JC001-JC007 logic and precedence; add alias warnings/metadata and
21
+ update identity wording. Emit schema 3 on completed and failed CLI output.
22
+ 6. Add focused AST/graph/producer unit tests and temporary Git rule,
23
+ deployment, JSON, determinism, Unicode, suppression, and security tests.
24
+ Update only intentional version/schema expectations in existing tests.
25
+ 7. Update README, architecture, changelog, version and lockfile. Run all
26
+ tests in available Ruby with UTF-8 locale, build and inspect the gem,
27
+ install into a temporary isolated destination, run CLI exit scenarios,
28
+ inspect the full diff, and remove only the generated package.
29
+
30
+ Mandala tracks specification, aliases, producers, compatibility, validation.
31
+ Only evidence-backed completed facets are marked done. No release operation
32
+ is part of this plan.
@@ -0,0 +1,109 @@
1
+ # jobcompat v0.3.0 release checklist
2
+
3
+ This document prepares jobcompat v0.3.0 for release. Checked boxes record completed preparation evidence. Unchecked boxes are final tag, publication, GitHub Release, or post-publication gates. This preparation does not authorize creating or pushing `v0.3.0`.
4
+
5
+ **Pushing `v0.3.0` triggers the Release workflow and may publish the gem to RubyGems. Treat tag push as a publication action.**
6
+
7
+ ## Release identity
8
+
9
+ - Repository: `cottondesu/jobcompat`, branch `main`.
10
+ - Gem and CLI version: `0.3.0`; intended annotated tag: `v0.3.0`.
11
+ - Intended release date: `2026-10-05`. If execution is delayed and publication is no longer intended for this date, update the release date consistently before committing or tagging; never backdate publication.
12
+ - JSON schema: `3`; `.jobcompat.yml` configuration schema: `1`.
13
+ - Ruby: `>= 3.3`; Prism: `>= 1.9, < 2`; no new runtime dependencies.
14
+ - Implementation commit: `642e2c4c838db78d4e7ae6dd9ff3b958b6336483` (`Add worker rename alias compatibility`), parent `6f6c2deecad10111d70455dd343e1f89a3af088c`.
15
+ - Implementation CI: run `37302967087`.
16
+ - Release candidate SHA: determined by this preparation commit; record the exact SHA and its exact CI run in the final preparation report. The release-preparation commit, not the implementation commit, must become the eventual tag target if its exact CI passes.
17
+
18
+ ## Reviewed implementation
19
+
20
+ - [x] Fresh independent review: SHIP; 0 BLOCKER and 0 MAJOR.
21
+ - [x] Final checked-in suite baseline: 189 tests, 1,942 assertions, 0 failures, 0 errors, 0 skips.
22
+ - [x] Reviewer documentation MINOR resolved before the implementation commit.
23
+ - [x] Analysis does not execute source, boot Rails or Sidekiq, or connect to Redis.
24
+
25
+ ## Implementation CI evidence
26
+
27
+ Implementation commit `642e2c4c838db78d4e7ae6dd9ff3b958b6336483` passed GitHub Actions run `37302967087`:
28
+
29
+ - [x] Ruby 3.3 tests and gem build succeeded.
30
+ - [x] Ruby 3.4 tests and gem build succeeded.
31
+ - [x] Ruby 4.0 tests and gem build succeeded.
32
+
33
+ This implementation CI does not replace the required official CI on the release-preparation commit.
34
+
35
+ ## Release-only local validation
36
+
37
+ - [x] `LANG=C.UTF-8 bundle exec rake test` under Ruby 3.4.8 / Prism 1.9.0: 189 tests, 1,942 assertions, 0 failures, 0 errors, 0 skips.
38
+ - [x] `git diff --check` passes.
39
+ - [x] `gem build jobcompat.gemspec` succeeds; package inspected and generated `.gem` removed after inspection.
40
+ - [x] Both v0.3.0 release documents are packaged under the existing `docs/*.md` gemspec policy; package contains no generated or local tooling files.
41
+ - [x] Installed the candidate gem into a fresh isolated destination; `jobcompat --version` prints `jobcompat 0.3.0`.
42
+ - [x] The full suite and alias integration scenarios verify safe alias 0, JC001 1, JC005 1, warning-only alias uncertainty 0, invalid config 2, and invalid ref 2.
43
+ - [x] Alias integration scenarios verify safe alias resolution, Class-object producer identity, exact Client String identity, and schema 3 fields; product tests were not changed.
44
+
45
+ ## Publication prerequisites
46
+
47
+ - [ ] Manually confirm GitHub environment `release` exists, its required reviewer/protection is intact, and its tag deployment rule remains acceptable. Do not weaken the environment.
48
+ - [ ] Manually confirm the RubyGems Trusted Publisher for `jobcompat` matches owner `cottondesu`, repository `jobcompat`, workflow `release.yml`, and environment `release`. Do not introduce a long-lived RubyGems API key.
49
+
50
+ ## Public-state precheck (2026-10-05)
51
+
52
+ - [x] RubyGems lists `0.2.1`, `0.2.0`, and `0.1.0`; `0.3.0` is absent.
53
+ - [x] The local `v0.3.0` tag is absent; remote tags include `v0.2.1` and no `v0.3.0`.
54
+ - [x] GitHub Releases include `v0.2.1`, `v0.2.0`, and `v0.1.0`; `v0.3.0` is absent.
55
+ - [x] Historical `v0.2.1` annotated tag object is `b9ca1f7a3d0818ee23beff48907cb3d6a2e0eb1c`, peeled commit is `6f6c2deecad10111d70455dd343e1f89a3af088c`, and the GitHub Release and RubyGems version are present.
56
+ - [ ] Immediately before final release execution, repeat all v0.3.0 absence checks. Stop if any already exists; never overwrite it.
57
+
58
+ ## Release workflow audit
59
+
60
+ The inspected `.github/workflows/release.yml` is unchanged. It triggers on tag pushes matching `v*`; the Ruby 3.3 / 3.4 / 4.0 test matrix gates `publish` through `needs: test`. Publication runs on Ruby 4.0 in environment `release`, grants GitHub OIDC `id-token: write`, checks tag/version and public metadata, configures RubyGems credentials with `rubygems/configure-rubygems-credentials`, then runs `gem push`.
61
+
62
+ - [x] Workflow trigger, test matrix, publish dependency, Ruby version, environment, OIDC permission, tag/version gate, metadata gate, and publication command audited.
63
+ - [ ] Confirm the GitHub release environment and RubyGems Trusted Publisher manually before the final tag action.
64
+
65
+ ## Release-note examples and content
66
+
67
+ - [x] Release notes were compared with `docs/spec-v0.3.md`, `README.md`, implementation, and tests for alias resolution, Class-object normalization, exact Client Strings, JC004/JC005, JC007 reasons, schema 3 fields, and config schema 1.
68
+ - [x] The alias integration scenarios and full local suite passed against the unchanged candidate implementation.
69
+
70
+ ## Tag gate
71
+
72
+ **Pushing `v0.3.0` triggers the Release workflow and may publish `jobcompat 0.3.0` automatically. Treat tag push as a publication action.** Before any separately approved final release execution:
73
+
74
+ - [ ] Release-preparation commit is fixed and its exact SHA identified.
75
+ - [ ] Exact release-preparation SHA has green official CI, including Ruby 3.3, Ruby 3.4, and Ruby 4.0.
76
+ - [ ] GitHub `release` environment gate has been manually verified.
77
+ - [ ] RubyGems Trusted Publisher settings have been manually verified.
78
+ - [ ] RubyGems `0.3.0`, local and remote `v0.3.0` tags, and GitHub Release `v0.3.0` are still absent.
79
+ - [ ] Separate final release execution approval has been obtained.
80
+
81
+ ## Future tag requirements
82
+
83
+ Documented for the separately approved release-execution phase; do not perform during preparation:
84
+
85
+ - Create an annotated `v0.3.0` tag pointing exactly to the verified release-preparation commit, not `642e2c4c838db78d4e7ae6dd9ff3b958b6336483`.
86
+ - Never create a lightweight tag, force a tag, or move an existing tag.
87
+
88
+ ## Release workflow verification
89
+
90
+ - [ ] Release workflow triggered by `v0.3.0`.
91
+ - [ ] Workflow tag resolves to the approved release-candidate SHA.
92
+ - [ ] Ruby 3.3, Ruby 3.4, and Ruby 4.0 test jobs succeed.
93
+ - [ ] Publish job starts only after tests; environment approval succeeds.
94
+ - [ ] Tag/version and metadata gates succeed.
95
+ - [ ] Publish-job tests and gem build succeed.
96
+ - [ ] OIDC credentials setup and gem push succeed.
97
+ - [ ] RubyGems publicly lists version `0.3.0`; do not infer publication solely from workflow status.
98
+
99
+ ## Post-publication
100
+
101
+ - [ ] Verify RubyGems version `0.3.0`, Ruby `>= 3.3`, Prism `>= 1.9, < 2`, metadata URLs, and package contents.
102
+ - [ ] Install from public RubyGems in a fresh environment and verify `jobcompat --version`.
103
+ - [ ] Run representative CLI checks against the installed public gem.
104
+ - [ ] Create or verify GitHub Release `v0.3.0` using the reviewed release notes and confirm it points to the correct tag.
105
+ - [ ] Record the release workflow URL.
106
+
107
+ ## Rollback
108
+
109
+ Do not overwrite or reuse `0.3.0`, move `v0.3.0`, or rewrite existing release history after publication. Fix forward with a later version. If a published gem must be removed, follow the official [RubyGems yank procedure](https://guides.rubygems.org/removing-a-published-gem/); yanking does not remove copies already installed.
@@ -0,0 +1,115 @@
1
+ # jobcompat v0.3.0 release notes
2
+
3
+ jobcompat v0.3.0 adds static worker-rename compatibility analysis for native Sidekiq. Supported aliases can keep old serialized worker names resolvable during rolling deployments. The release also models the difference between Sidekiq producers that pass a Class object and Client producers that pass an exact String identity.
4
+
5
+ ## Highlights
6
+
7
+ - Recognize supported static compatibility aliases such as `OldJob = NewJob`.
8
+ - Resolve finite alias chains and supported namespaces conservatively.
9
+ - Avoid a false JC004 when a retained old serialized identity still resolves through a safe alias.
10
+ - Normalize Class-object producer identities through aliases to their terminal Class names.
11
+ - Preserve exact `Sidekiq::Client` String identities.
12
+ - Report relevant alias uncertainty as JC007 instead of assuming compatibility.
13
+ - Upgrade JSON output to schema version 3 and include alias provenance.
14
+
15
+ ## Safe worker rename compatibility aliases
16
+
17
+ For a rename, keep a static alias while old payloads may still be retained:
18
+
19
+ ```ruby
20
+ class NewExportJob
21
+ include Sidekiq::Job
22
+
23
+ def perform(id)
24
+ end
25
+ end
26
+
27
+ OldExportJob = NewExportJob
28
+ ```
29
+
30
+ Queued, retried, or scheduled payloads already serialized as `"OldExportJob"` can resolve through this alias to the `NewExportJob` contract. A safe alias can therefore prevent JC004 for `OldExportJob` while those payloads may still run.
31
+
32
+ An alias does not hide a real argument incompatibility. If the terminal worker no longer accepts an old payload's positional arity, jobcompat can still report JC001 or JC006 under the existing rules.
33
+
34
+ Supported aliases are unconditional plain constant assignments, including supported namespace forms. Finite chains are resolved; for example, `LegacyExportJob = PreviousExportJob` and `PreviousExportJob = ExportJob` make `ExportJob` the effective consumer. This is a documented static subset, not general Ruby constant or alias resolution.
35
+
36
+ ## Serialized identity: Class objects vs exact Strings
37
+
38
+ Ruby aliases refer to the same Class object. Given:
39
+
40
+ ```ruby
41
+ OldExportJob = NewExportJob
42
+ OldExportJob.perform_async(1)
43
+ ```
44
+
45
+ the source token is `OldExportJob`, but the value passed to Sidekiq is the `NewExportJob` Class object. Sidekiq serializes that object's canonical name, so jobcompat models the persisted identity as `NewExportJob`. During a rolling deployment, activating this producer can still produce JC005 for `NewExportJob` if the old fleet does not recognize that name.
46
+
47
+ **Keeping `OldExportJob = NewExportJob` protects retained old `"OldExportJob"` payloads, but calling `OldExportJob.perform_async(...)` is not a mechanism for continuing to enqueue the old serialized class name.**
48
+
49
+ An explicit Client String has different identity behavior:
50
+
51
+ ```ruby
52
+ Sidekiq::Client.push(
53
+ "class" => "OldExportJob",
54
+ "args" => [1]
55
+ )
56
+ ```
57
+
58
+ Here the serialized identity remains exactly `OldExportJob`; jobcompat models that String as-is and can resolve it through the consumer-side alias. Client String pushes are described to explain identity modeling, not as the default rename strategy; their job-option and default semantics differ.
59
+
60
+ ## Conservative alias uncertainty
61
+
62
+ jobcompat does not assume compatibility when Ruby behavior is ambiguous. Relevant uncertainty is reported as JC007 with one of these reasons:
63
+
64
+ - `alias_target_unresolved`: the target cannot be proven to end at a recognized worker.
65
+ - `alias_cycle`: aliases refer back to each other.
66
+ - `alias_binding_conflict`: the same constant has conflicting definitions or writes.
67
+ - `unsupported_alias_assignment`: the assignment is conditional, dynamic, operator-based, or otherwise outside the supported syntax.
68
+
69
+ Unknown does not mean compatible. jobcompat does not execute application source to resolve these cases. It supports plain static assignments such as `OldJob = NewJob`, supported qualified paths, and supported namespace bodies. It does not resolve `const_set`, `autoload`, `const_missing`, conditional aliases, dynamic right-hand-side evaluation, runtime load order, general Ruby constant evaluation, or heuristic renames.
70
+
71
+ ## JSON schema 3
72
+
73
+ **v0.3.0 changes JSON `schema_version` from 2 to 3.** `workers[]` now represents compared serialized worker identities and exposes alias provenance. Each worker result includes `base_alias` and `head_alias`; the presence value `resolved_alias` identifies a successfully resolved alias. `summary.workers.base` and `.head` continue to count directly recognized Sidekiq workers, not aliases.
74
+
75
+ The `.jobcompat.yml` configuration schema remains `version: 1`. Do not change the configuration version to 3.
76
+
77
+ ## Compatibility contracts
78
+
79
+ No new JC rule was added. JC001–JC007 IDs, severities, JC001/JC003 precedence, the three rolling-deployment directions, exit codes, and suppression identity remain unchanged. The configuration schema remains version 1, Ruby `>= 3.3` remains required, and Prism `>= 1.9, < 2` remains the supported range.
80
+
81
+ ## Upgrade notes
82
+
83
+ **Consumers that parse jobcompat JSON must update to accept `schema_version: 3` before upgrading to v0.3.0.** Do not assume that a schema 2 consumer can read schema 3 unless that consumer explicitly supports new schema versions and fields.
84
+
85
+ No `.jobcompat.yml` version change is needed. Keep a compatibility alias for as long as old serialized identities can remain in queues, retries, schedules, or other retained payloads. Plan producer activation separately: calling an aliased Class object's Sidekiq API serializes the terminal Class name.
86
+
87
+ ## Known limitations
88
+
89
+ Analysis covers positional arity for native Sidekiq workers only. ActiveJob and keyword compatibility are unsupported. jobcompat does not inspect Hash internal schemas, payload value types, live Redis contents, general dataflow, arbitrary Client instances, wrapper inference, runtime load order, or general aliasing and metaprogramming. It does not execute application code.
90
+
91
+ No repository producer callsite does not prove an empty queue. Queued, scheduled, retried, historical, and external jobs may still exist; static analysis does not establish runtime queue contents.
92
+
93
+ ## Installation
94
+
95
+ ```bash
96
+ gem install jobcompat --version 0.3.0
97
+ ```
98
+
99
+ Bundler users can add:
100
+
101
+ ```ruby
102
+ gem "jobcompat", "~> 0.3.0", require: false
103
+ ```
104
+
105
+ Then fetch the comparison ref and run:
106
+
107
+ ```bash
108
+ jobcompat check --base origin/main
109
+ ```
110
+
111
+ or:
112
+
113
+ ```bash
114
+ bundle exec jobcompat check --base origin/main
115
+ ```
data/docs/spec-v0.3.md ADDED
@@ -0,0 +1,238 @@
1
+ # jobcompat v0.3.0 delta specification
2
+
3
+ Status: normative for 0.3.0. This document extends `spec-v0.2.1.md`,
4
+ which retains the v0.2 and v0.1 contracts except where overridden here.
5
+ MUST and MUST NOT state requirements. No application Ruby is executed.
6
+
7
+ ## Versioned and frozen contracts
8
+
9
+ Gem and CLI version: 0.3.0. Completed and failed JSON envelopes MUST use
10
+ `schema_version: 3`. Configuration version remains exactly 1; version 2 is
11
+ invalid. Ruby >= 3.3 and Prism >= 1.9, < 2 remain unchanged. No dependency
12
+ on Sidekiq is introduced. JC001-JC007 IDs, severities (JC001-JC005 ERROR,
13
+ JC006-JC007 WARNING), exit policy, suppression identity, and deployment
14
+ directions remain unchanged. ERROR requires provable structural
15
+ incompatibility. Unknown and unsupported evidence never establishes
16
+ compatibility; missing producers never establishes an empty queue.
17
+ Analysis reads committed Git objects and ignores working-tree edits.
18
+
19
+ ## Terminology and serialization
20
+
21
+ **Serialized worker identity** is the exact String in a Sidekiq payload's
22
+ `"class"` field. All compatibility comparisons use that identity.
23
+ **Direct worker** is a canonical class recognized through the existing
24
+ direct `Sidekiq::Job` or legacy `Sidekiq::Worker` include and reopened-class
25
+ discovery. **Compatibility alias** is a statically provable constant
26
+ binding to another constant whose graph terminates in one recognized
27
+ direct worker in the same revision. **Alias terminal** is that final
28
+ direct worker after zero or more supported edges.
29
+ **Class-object producer** passes a Ruby Class object (worker APIs,
30
+ Setter APIs, or Client constant payloads). **Exact-string producer** passes
31
+ an already serialized identity through Client push or push_bulk.
32
+
33
+ For `OldJob = NewJob`, Ruby retains the Class object's canonical name
34
+ `NewJob`. Class-object producers through OldJob MUST therefore normalize
35
+ to NewJob. This applies to perform_async, perform_in, perform_at,
36
+ perform_bulk, all four `.set(...)` forms, and Client push/push_bulk with a
37
+ constant. A Client `"class" => "OldJob"` MUST retain exactly OldJob on the
38
+ producer side; consumer lookup MAY resolve OldJob through its alias.
39
+ There is no namespace prefixing or Unicode normalization of exact Strings.
40
+
41
+ The model freezes Sidekiq's Class-to-String normalization as observed in
42
+ [Sidekiq 8.1.6 JobUtil](https://github.com/sidekiq/sidekiq/blob/v8.1.6/lib/sidekiq/job_util.rb#L38-L55)
43
+ and its [Class-object producer path](https://github.com/sidekiq/sidekiq/blob/v8.1.6/lib/sidekiq/job.rb#L191-L196).
44
+ The [official rename FAQ](https://github.com/sidekiq/sidekiq/wiki/FAQ#how-do-i-safely-rename-a-worker)
45
+ describes a compatibility alias for retained old payloads. Client Strings
46
+ have different job-option/default semantics; their identity behavior is
47
+ documented here without recommending them as a migration strategy.
48
+
49
+ ## Supported syntax and namespace semantics
50
+
51
+ Only Prism ConstantWriteNode and ConstantPathWriteNode with plain `=`
52
+ and a ConstantReadNode or static ConstantPathNode RHS after safe
53
+ single-expression parentheses unwrapping prove an alias. The assignment
54
+ MUST be a direct unconditional statement in the Program body or a valid
55
+ Class/Module body, with all enclosing contexts supported.
56
+
57
+ Supported examples are `OldJob = NewJob`, `OldJob = (NewJob)`,
58
+ `::OldJob = ::NewJob`, top-level `Admin::OldJob = Admin::NewJob`, and
59
+ `module Admin; OldJob = NewJob; end`. Existing declaration rules determine
60
+ the LHS canonical name. An unrooted qualified LHS in lexical nesting or
61
+ an unsupported namespace MUST NOT prove an alias. An unrooted qualified
62
+ RHS inside lexical nesting is unsupported_alias_assignment. Rooted RHS
63
+ references and qualified top-level RHS references are exact.
64
+
65
+ Unqualified RHS lookup uses the existing lexical namespace stack in
66
+ reverse order, then the root name. For nested Admin/Jobs, NewJob candidates
67
+ are Admin::Jobs::NewJob, Admin::NewJob, NewJob. The first selected-source
68
+ binding wins lookup; a binding with an unknown value MUST block fallback
69
+ to a farther known worker. No inherited-constant lookup is added.
70
+
71
+ Assignments below if/unless/case/loops/blocks/lambdas/begin/rescue/ensure,
72
+ modifier conditions, singleton classes, methods, or expression wrappers
73
+ are unsupported. Even an unconditional begin wrapper is unsupported.
74
+ Or/and/operator writes, multiple assignment targets, dynamic RHS,
75
+ ternary RHS, calls, and Class.new MUST NOT become resolved aliases.
76
+ Named unsupported writes enter the inventory so relevant identities can
77
+ warn and conflicts cannot be hidden. const_set/autoload/const_missing
78
+ remain outside alias discovery.
79
+
80
+ ## Selected binding inventory and graph
81
+
82
+ During the existing Prism traversal, record selected-source class/module
83
+ declarations and named writes, plus alias candidates. Presence facts in
84
+ DefinedConstantIndex MUST remain separate from alias graph facts.
85
+ Exactly one supported alias binding and no other binding to its LHS is
86
+ required. Duplicate aliases, even identical ones, alias plus class/module,
87
+ or alias plus unsupported writes produce alias_binding_conflict. No
88
+ source-order winner is selected. Existing reopened direct class fragments
89
+ are allowed and merged as before; they are not conflicts by themselves.
90
+ An alias that reaches a conflicting binding MUST remain unknown.
91
+ Selected ambiguous constant paths also retain leaf-name conflict evidence:
92
+ an assignment or declaration such as `self::OldJob = OtherJob` cannot be
93
+ ignored when its leaf may affect an alias or a reached terminal. These
94
+ facts conservatively block resolution without evaluating the receiver.
95
+ Excluded or differently named ambiguous facts do not enter this proof.
96
+
97
+ Each revision resolves its own graph independently. Finite chains have
98
+ no arbitrary length cap. Iterative traversal detects cycles without
99
+ recursive stack overflow. Resolved chain order follows edges, for example
100
+ `[OldJob, MiddleJob, NewJob]`. A cycle chain closes with its repeated
101
+ identity, for example `[OldJob, MiddleJob, OldJob]`. A missing or
102
+ unrecognized terminal is alias_target_unresolved. Failed suffixes
103
+ propagate their reason and proof to aliases depending on them.
104
+
105
+ Only selected files supply trusted edges. Excluded declarations/writes
106
+ may block absence through the presence pass, but MUST NOT resolve aliases.
107
+ An excluded OldJob alias yields outside_analysis_scope for a removal
108
+ transition; a selected alias targeting an excluded worker yields
109
+ alias_target_unresolved. File movement alone does not alter identity
110
+ compatibility. All keys preserve Prism's exact UTF-8 codepoints.
111
+
112
+ ## Effective consumers and relevant identities
113
+
114
+ Snapshot workers remain directly recognized workers. An effective
115
+ consumer map resolves serialized identity S to either direct worker S or
116
+ the terminal contract of supported alias S. A conflicting alias masks an
117
+ otherwise recognized direct consumer at the same identity. Resolved
118
+ aliases retain resolution status even when the terminal signature is
119
+ unknown (keyword_parameters, missing_perform, multiple_perform_definitions).
120
+ Effective alias contracts carry every edge location and the terminal's
121
+ declaration/include/perform proof. No new location role is introduced.
122
+
123
+ Comparison identities MUST include all direct workers in either revision,
124
+ all resolved BASE aliases (historical queues may retain their names),
125
+ aliases present in both revisions, aliases matching an opposite direct
126
+ worker, and aliases referenced by visible producers or needed to explain
127
+ unknown producer identity. A dormant HEAD-only alias is not automatically
128
+ a worker result or standalone warning. The full graph may still be built.
129
+ The common-alias criterion refers to constant-to-constant candidates,
130
+ including unsupported static forms; unrelated dynamic/literal writes such
131
+ as VERSION = "1" do not become relevant merely by existing in both
132
+ revisions. They still block conflicting bindings and are diagnosed when
133
+ referenced by a producer or matched to an opposite worker identity.
134
+
135
+ ## Producer resolution
136
+
137
+ Class-object resolution first performs existing lexical/root lookup in
138
+ the producer's own snapshot, including alias bindings. A resolved alias
139
+ normalizes to its terminal direct identity. An unknown alias MUST NOT
140
+ guess its syntactic name as a serialized identity. Such a call retains a
141
+ separate attributed alias name for diagnostics and comparison relevance,
142
+ but cannot drive JC005. Existing cross-revision non-alias attribution
143
+ remains available only when no local alias/binding narrows that proof.
144
+ When alias lookup applies, a nearer selected non-worker class/module
145
+ binding stops fallback and leaves producer identity unknown. Opposite
146
+ revision names cannot override a selected local binding. Normalized
147
+ Class-object alias producers retain edge and terminal declaration,
148
+ include, and perform locations; unresolved targets retain any selected
149
+ declaration that prevents recognition.
150
+
151
+ Exact-string resolution remains exact and does not canonicalize aliases.
152
+ Producer identity certainty and arity certainty MUST be distinct:
153
+ resolved alias plus `*args` retains the terminal identity and splat_arguments;
154
+ unknown alias plus literal args retains unknown identity. Known identity
155
+ with unknown arity can still drive JC005. Existing Hash source-order,
156
+ dynamic-key, bulk-row, lexical producer, and Client String rules persist.
157
+
158
+ ## Rules, unknown reasons, and proof
159
+
160
+ JC001: old serialized payload accepted by BASE but rejected by HEAD,
161
+ including alias consumers. JC002: HEAD accepted payload rejected by BASE,
162
+ under its serialized identity. JC003: HEAD producer/consumer mismatch.
163
+ JC001 owns a repeated HEAD mismatch at the same identity/arity before
164
+ JC003. JC001 OldJob and JC003 NewJob from a Class alias producer remain
165
+ distinct findings. JC006 narrowing/witness precedence remains unchanged.
166
+
167
+ JC004 requires absence of a BASE effective serialized identity in HEAD.
168
+ A resolved alias blocks removal; a relevant unknown alias warns rather
169
+ than passing or proving absence. Removing a BASE resolved alias can cause
170
+ JC004 even if its terminal still exists. JC005 requires HEAD known
171
+ serialized producer identity with an effective HEAD consumer and proven
172
+ BASE absence, conditional on old processes consuming that queue. An
173
+ exact-string alias producer can cause JC005 for the alias; a Class-object
174
+ alias producer can cause JC005 only for the terminal. BASE resolved aliases
175
+ block absence; unresolved aliases warn. Existing presence budget/error
176
+ behavior and conservative absence proof remain frozen.
177
+
178
+ JC007 adds exactly four reasons: alias_target_unresolved (supported edge
179
+ does not reach a recognized worker), alias_cycle, alias_binding_conflict,
180
+ and unsupported_alias_assignment. Existing contract/arity reasons retain
181
+ their meanings. Alias warning messages describe the unresolved binding
182
+ and risk to the persisted identity; remediation suggests an explicit
183
+ static alias only if it matches runtime behavior, or manual inspection.
184
+
185
+ BASE alias uncertainty affects head_to_base. HEAD alias uncertainty affects
186
+ base_to_head and additionally head_to_head only with a HEAD producer for
187
+ that same serialized identity. A Class call normalizing to another name
188
+ does not manufacture head_to_head for its source alias. Alias evidence
189
+ fingerprints contain reason, exact identity, lexical scope, normalized
190
+ assignment/enclosing tokens, target reference, and structural chain, but
191
+ exclude revision, paths, lines, and columns. Stable binding/occurrence
192
+ ordering preserves distinct evidence and cross-revision aggregation.
193
+
194
+ ## JSON schema 3 and determinism
195
+
196
+ workers[] now represents compared serialized identities, sorted by exact
197
+ name. Every result has base_alias and head_alias, null for a direct or
198
+ absent binding, otherwise an object with exactly status, target, chain,
199
+ unknown_reason. Resolved status has terminal target and null reason;
200
+ unknown status has null target and its alias reason. Conflict or unsupported
201
+ assignment chains start with the alias alone; unresolved target chains
202
+ include the missing target, and cycle chains close the cycle.
203
+
204
+ Presence values are recognized_worker, resolved_alias,
205
+ defined_unrecognized, outside_scan_scope, absent, unverified, not_checked.
206
+ Unknown aliases use defined_unrecognized even in a direct binding conflict.
207
+ Effective contracts appear in base_contract/head_contract for resolved
208
+ aliases; unresolved aliases have null contracts. Producer arities and
209
+ matrix statuses use known serialized identity and effective consumers.
210
+ summary.workers continues to count direct worker facts only, never aliases.
211
+ Suppressions match the finding's exact serialized worker identity.
212
+
213
+ Inputs and graph processing use stable sorting. Chains follow structural
214
+ edge order. Repeated text/JSON for identical refs/config/toolchain MUST be
215
+ byte-identical, including cycles, conflicts, and Unicode names.
216
+
217
+ ## Boundaries and acceptance
218
+
219
+ Recognition assumes normal successful application boot, as direct worker
220
+ recognition already does; it does not prove file load order or Zeitwerk
221
+ behavior. No general constant evaluator/dataflow, inheritance/concerns,
222
+ source execution, Rails/Sidekiq boot, Redis/network access, keyword
223
+ compatibility, value/Hash schema analysis, ActiveJob, Client instances,
224
+ wrappers, queue/feature-flag simulation, manual alias config, heuristic
225
+ rename detection, JC008, SARIF, --explain, or coverage report is added.
226
+
227
+ Acceptance requires supported AST/context and namespace tests, finite and
228
+ cross-file graph tests, cycles/conflicts/unknown terminals, reopened and
229
+ unknown-contract targets, all Class-object and exact-string producer
230
+ families, separate identity/arity uncertainty, every JC rule and precedence,
231
+ staged rename and alias removal, excluded-source presence proof, exact
232
+ Unicode/suppression behavior, schema 3 (success and failure), unchanged
233
+ config version 1, deterministic output, existing regressions, source
234
+ non-execution/worktree/index immutability, offline tests, gem packaging,
235
+ isolated CLI install/scenarios, and full validation in UTF-8 locale.
236
+ Locally unavailable Ruby 3.3/4.0 matrix entries remain official CI work.
237
+ The implementation phase MUST leave an uncommitted candidate and MUST NOT
238
+ commit, push, tag, release, publish, or alter historical v0.2.1 artifacts.
@@ -0,0 +1,86 @@
1
+ module Jobcompat
2
+ ConstantBinding = Data.define(:name, :kind, :location, :fingerprint_parts)
3
+ AliasCandidate = Data.define(:name, :reference, :root, :namespace, :supported, :location, :fingerprint_parts)
4
+ AliasResolution = Data.define(:name, :status, :target, :chain, :unknown_reason, :locations, :fingerprint_parts, :static_reference) do
5
+ def resolved? = status == "resolved"
6
+ def as_json
7
+ {status: status, target: target, chain: chain, unknown_reason: unknown_reason}
8
+ end
9
+ end
10
+
11
+ module ConstantLookup
12
+ def self.candidates(reference, root, namespace)
13
+ return [reference] if root
14
+ return [] if namespace.nil?
15
+ (namespace.reverse.map { |prefix| "#{prefix}::#{reference}" } + [reference]).uniq
16
+ end
17
+ end
18
+
19
+ class AliasResolver
20
+ def initialize(workers, bindings, candidates, ambiguous_bindings = [])
21
+ @workers = workers
22
+ @bindings = bindings.group_by(&:name)
23
+ @candidates = candidates.group_by(&:name)
24
+ @ambiguous_bindings = ambiguous_bindings.group_by(&:name)
25
+ end
26
+
27
+ def resolve
28
+ @candidates.keys.sort.to_h { |name| [name, resolve_identity(name)] }.freeze
29
+ end
30
+
31
+ private
32
+
33
+ def resolve_identity(name)
34
+ chain, locations, parts, visited = [], [], [], {}
35
+ current = name
36
+ reason = nil
37
+ loop do
38
+ chain << current
39
+ if visited[current]
40
+ reason = "alias_cycle"
41
+ break
42
+ end
43
+ visited[current] = true
44
+ candidates = @candidates.fetch(current, [])
45
+ bindings = @bindings.fetch(current, []).sort_by { |item| [item.location.path, item.location.line, item.location.column] }
46
+ ambiguous = @ambiguous_bindings.fetch(current.split("::").last, []).reject { |item| bindings.any? { |binding| binding.location == item.location } }
47
+ if candidates.empty? && @workers.key?(current) && ambiguous.empty?
48
+ return resolution(name, current, chain, nil, locations, parts)
49
+ end
50
+ locations.concat((bindings + ambiguous).map(&:location))
51
+ parts << [current, (bindings + ambiguous).map(&:fingerprint_parts).sort_by(&:inspect)]
52
+ unless ambiguous.empty?
53
+ reason = "alias_binding_conflict"
54
+ break
55
+ end
56
+ if candidates.empty?
57
+ reason = "alias_target_unresolved"
58
+ break
59
+ end
60
+ if bindings.length != 1
61
+ reason = "alias_binding_conflict"
62
+ break
63
+ end
64
+ candidate = candidates.first
65
+ unless candidate.supported
66
+ reason = "unsupported_alias_assignment"
67
+ break
68
+ end
69
+ lookup = ConstantLookup.candidates(candidate.reference, candidate.root, candidate.namespace)
70
+ target = lookup.find { |identity| @bindings.key?(identity) || @workers.key?(identity) }
71
+ unless target
72
+ chain << lookup.first
73
+ reason = "alias_target_unresolved"
74
+ break
75
+ end
76
+ current = target
77
+ end
78
+ resolution(name, nil, chain, reason, locations, parts)
79
+ end
80
+
81
+ def resolution(name, target, chain, reason, locations, parts)
82
+ AliasResolution.new(name, reason ? "unknown" : "resolved", target, chain.freeze, reason,
83
+ locations.uniq.freeze, parts.freeze, @candidates.fetch(name).any? { |candidate| candidate.reference })
84
+ end
85
+ end
86
+ end
@@ -1,5 +1,6 @@
1
1
  require "prism"
2
2
  require_relative "canonical_worker_name"
3
+ require_relative "aliases"
3
4
 
4
5
  module Jobcompat
5
6
  Location = Data.define(:revision, :path, :line, :column, :role, :excerpt) do
@@ -56,14 +57,32 @@ module Jobcompat
56
57
  end
57
58
 
58
59
  class RevisionSnapshot
59
- attr_reader :label, :ref, :sha, :workers, :calls, :unknowns, :index, :files_scanned, :tracked_ruby
60
+ attr_reader :label, :ref, :sha, :workers, :aliases, :bindings, :calls, :unknowns, :index, :files_scanned, :tracked_ruby
60
61
 
61
- def initialize(label:, ref:, sha:, workers:, calls:, unknowns:, index:, files_scanned:, tracked_ruby:)
62
+ def initialize(label:, ref:, sha:, workers:, calls:, unknowns:, index:, files_scanned:, tracked_ruby:, aliases: {}, bindings: [])
62
63
  @label, @ref, @sha, @workers, @calls, @unknowns, @index, @files_scanned, @tracked_ruby = label, ref, sha, workers, calls, unknowns, index, files_scanned, tracked_ruby
64
+ @aliases = aliases
65
+ @bindings = bindings.group_by(&:name).freeze
66
+ end
67
+
68
+ def consumer(name)
69
+ binding = aliases[name]
70
+ return workers[name] unless binding
71
+ return unless binding.resolved?
72
+ terminal = workers.fetch(binding.target)
73
+ terminal.with(name: name, locations: (binding.locations + terminal.locations).uniq,
74
+ declaration_locations: (binding.locations + terminal.declaration_locations).uniq,
75
+ fingerprint_parts: [binding.fingerprint_parts, terminal.fingerprint_parts])
76
+ end
77
+
78
+ def presence(name)
79
+ binding = aliases[name]
80
+ return binding.resolved? ? "resolved_alias" : "defined_unrecognized" if binding
81
+ index.status(name, recognized: workers.key?(name))
63
82
  end
64
83
 
65
84
  def check_presence(names, repository, config)
66
- unresolved = names.reject { |name| index.status(name, recognized: workers.key?(name)) != "absent" }
85
+ unresolved = names.select { |name| presence(name) == "absent" }
67
86
  return if unresolved.empty?
68
87
  consumed = 0
69
88
  eligible = []
@@ -105,11 +124,12 @@ module Jobcompat
105
124
  PATH_CONSTANT_BINDINGS = [Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode,
106
125
  Prism::ConstantPathAndWriteNode, Prism::ConstantPathOperatorWriteNode,
107
126
  Prism::ConstantPathTargetNode].freeze
108
- attr_reader :fragments, :calls, :unknowns
127
+ attr_reader :fragments, :calls, :unknowns, :bindings, :alias_candidates, :ambiguous_bindings
109
128
 
110
129
  def initialize(revision, path, source, index:, presence_only: false)
111
130
  @revision, @path, @source, @index, @presence_only = revision, path, source, index, presence_only
112
131
  @fragments, @calls, @unknowns = [], [], []
132
+ @bindings, @alias_candidates, @ambiguous_bindings = [], [], []
113
133
  end
114
134
 
115
135
  def analyze
@@ -122,7 +142,7 @@ module Jobcompat
122
142
  end
123
143
  raise Error.new(diagnostics.first[:message], category: "parse_error", location: diagnostics.first[:location], diagnostics: diagnostics)
124
144
  end
125
- walk(result.value, [], [], nil)
145
+ walk(result.value, [], [], nil, true)
126
146
  self
127
147
  end
128
148
 
@@ -156,19 +176,30 @@ module Jobcompat
156
176
 
157
177
  private
158
178
 
159
- def walk(node, namespace, scope, statement)
179
+ def walk(node, namespace, scope, statement, alias_context = false, alias_statement = nil)
160
180
  return unless node.is_a?(Prism::Node)
161
181
  case node
182
+ when Prism::ProgramNode
183
+ walk(node.statements, namespace, scope, nil, alias_context)
162
184
  when Prism::StatementsNode
163
- node.body.each { |child| walk(child, namespace, scope, child) }
185
+ node.body.each { |child| walk(child, namespace, scope, child, alias_context, alias_context ? child : (alias_statement || child)) }
164
186
  when Prism::ClassNode, Prism::ModuleNode
165
187
  constant = self.class.constant(node.constant_path)
166
188
  name, valid = declaration_name(constant, namespace)
167
189
  location = loc(node, "worker_declaration")
168
190
  @index.add(name, location, selected: !@presence_only, ambiguous: !valid) if name
191
+ if name && !@presence_only
192
+ @bindings << ConstantBinding.new(name, "declaration", location,
193
+ [scope, self.class.normalize(node.constant_path.location.slice), node.class.name])
194
+ end
169
195
  if !name && node.constant_path.is_a?(Prism::ConstantPathNode)
170
196
  @index.add(node.constant_path.name.to_s.encode(Encoding::UTF_8), location, selected: !@presence_only, ambiguous: true)
171
197
  end
198
+ unless valid || @presence_only
199
+ leaf = name&.split("::")&.last || node.constant_path.name.to_s.encode(Encoding::UTF_8)
200
+ @ambiguous_bindings << ConstantBinding.new(leaf, "declaration", location,
201
+ [scope, self.class.normalize(node.constant_path.location.slice), node.class.name])
202
+ end
172
203
  new_scope = scope + [name || "<unsupported_class>"]
173
204
  if node.is_a?(Prism::ClassNode) && valid && !@presence_only
174
205
  body = node.body.is_a?(Prism::StatementsNode) ? node.body.body : []
@@ -185,24 +216,25 @@ module Jobcompat
185
216
  [@path, new_scope, self.class.normalize(node.constant_path.location.slice), include_tokens])
186
217
  end
187
218
  child_namespace = valid && namespace ? namespace + [name] : nil
188
- walk(node.superclass, namespace, scope, statement) if node.is_a?(Prism::ClassNode) && node.superclass
189
- walk(node.body, child_namespace, new_scope, nil) if node.body
219
+ walk(node.superclass, namespace, scope, statement, false, alias_statement) if node.is_a?(Prism::ClassNode) && node.superclass
220
+ body_context = alias_context && node.equal?(statement) && valid
221
+ walk(node.body, child_namespace, new_scope, nil, body_context, body_context ? nil : alias_statement) if node.body
190
222
  when Prism::DefNode
191
223
  method_scope = node.receiver ? [self.class.normalize(node.receiver.location.slice), node.name.to_s] : node.name.to_s
192
224
  child_scope = scope + [method_scope]
193
- walk(node.receiver, namespace, scope, statement) if node.receiver
194
- walk(node.parameters, namespace, child_scope, node.parameters) if node.parameters
195
- walk(node.body, namespace, child_scope, nil) if node.body
225
+ walk(node.receiver, namespace, scope, statement, false, alias_statement) if node.receiver
226
+ walk(node.parameters, namespace, child_scope, node.parameters, false, alias_statement) if node.parameters
227
+ walk(node.body, namespace, child_scope, nil, false, alias_statement) if node.body
196
228
  when Prism::SingletonClassNode
197
- walk(node.expression, namespace, scope, statement)
229
+ walk(node.expression, namespace, scope, statement, false, alias_statement)
198
230
  singleton_scope = scope + [["singleton_class", self.class.normalize(node.expression.location.slice)]]
199
- walk(node.body, namespace, singleton_scope, nil) if node.body
231
+ walk(node.body, namespace, singleton_scope, nil, false, alias_statement) if node.body
200
232
  when Prism::CallNode
201
233
  extract_call(node, namespace, scope, statement) unless @presence_only
202
- node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement) }
234
+ node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement, false, alias_statement || statement) }
203
235
  else
204
- constant_write(node, namespace) if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) } || PATH_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
205
- node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement) }
236
+ constant_write(node, namespace, scope, alias_statement || statement, alias_context && node.equal?(statement)) if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) } || PATH_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
237
+ node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement, false, alias_statement || statement) }
206
238
  end
207
239
  end
208
240
 
@@ -214,10 +246,12 @@ module Jobcompat
214
246
  [((rooted || segments.length > 1) ? segments : (namespace.empty? ? [] : namespace.last.split("::")) + segments).join("::"), true]
215
247
  end
216
248
 
217
- def constant_write(node, namespace)
249
+ def constant_write(node, namespace, scope, statement, alias_context)
218
250
  if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
219
251
  prefix = namespace.nil? || namespace.empty? ? [] : namespace.last.split("::")
220
- @index.add((prefix + [node.name.to_s.encode(Encoding::UTF_8)]).join("::"), loc(node, "worker_declaration"),
252
+ name = (prefix + [node.name.to_s.encode(Encoding::UTF_8)]).join("::")
253
+ valid = !namespace.nil?
254
+ @index.add(name, loc(node, "worker_declaration"),
221
255
  selected: !@presence_only, ambiguous: namespace.nil?)
222
256
  else
223
257
  target = node.is_a?(Prism::ConstantPathTargetNode) ? node : node.target
@@ -228,6 +262,22 @@ module Jobcompat
228
262
  @index.add(target.name.to_s.encode(Encoding::UTF_8), loc(node, "worker_declaration"), selected: !@presence_only, ambiguous: true)
229
263
  end
230
264
  end
265
+ return if @presence_only
266
+ unless valid
267
+ leaf = name&.split("::")&.last || target.name.to_s.encode(Encoding::UTF_8)
268
+ @ambiguous_bindings << ConstantBinding.new(leaf, "write", loc(node, "worker_declaration"),
269
+ [scope, self.class.normalize(node.location.slice), self.class.normalize((statement || node).location.slice)])
270
+ end
271
+ return if name.nil?
272
+ plain = node.is_a?(Prism::ConstantWriteNode) || node.is_a?(Prism::ConstantPathWriteNode)
273
+ reference = self.class.constant(node.value) if node.respond_to?(:value)
274
+ supported = plain && valid && alias_context && reference &&
275
+ (reference.last || reference.first.length == 1 || namespace.empty?)
276
+ location = loc(node, "worker_declaration")
277
+ parts = [scope, self.class.normalize(node.location.slice), self.class.normalize((statement || node).location.slice), reference]
278
+ @bindings << ConstantBinding.new(name, "write", location, parts)
279
+ @alias_candidates << AliasCandidate.new(name, reference && reference.first.join("::"), reference && reference.last,
280
+ namespace, !!supported, location, parts)
231
281
  end
232
282
 
233
283
  def sidekiq_include?(node)
@@ -421,7 +471,7 @@ module Jobcompat
421
471
  selected = entries.select { |entry| @config.scan?(entry.path) }
422
472
  tracked = entries.select { |entry| entry.path.end_with?(".rb") }
423
473
  index = DefinedConstantIndex.new
424
- fragments, calls, unknowns, errors = [], [], [], []
474
+ fragments, calls, unknowns, errors, bindings, alias_candidates, ambiguous_bindings = [], [], [], [], [], [], []
425
475
  @repository.each_blob(selected) do |entry, bytes|
426
476
  path = entry.path
427
477
  begin
@@ -429,6 +479,9 @@ module Jobcompat
429
479
  fragments.concat(result.fragments)
430
480
  calls.concat(result.calls)
431
481
  unknowns.concat(result.unknowns)
482
+ bindings.concat(result.bindings)
483
+ alias_candidates.concat(result.alias_candidates)
484
+ ambiguous_bindings.concat(result.ambiguous_bindings)
432
485
  rescue Error => error
433
486
  errors << error
434
487
  end
@@ -470,8 +523,9 @@ module Jobcompat
470
523
  workers[name] = Contract.new(name, required, rest ? nil : required + optional, forwarding ? "forwarding" : "positional", signature, nil, locations, declarations, fingerprint_parts)
471
524
  end
472
525
  end
473
- RevisionSnapshot.new(label: label, ref: ref, sha: sha, workers: workers, calls: calls, unknowns: unknowns, index: index,
474
- files_scanned: selected.length, tracked_ruby: tracked)
526
+ aliases = AliasResolver.new(workers, bindings, alias_candidates, ambiguous_bindings).resolve
527
+ RevisionSnapshot.new(label: label, ref: ref, sha: sha, workers: workers, aliases: aliases, calls: calls, unknowns: unknowns, index: index,
528
+ files_scanned: selected.length, tracked_ruby: tracked, bindings: bindings)
475
529
  end
476
530
  end
477
531
  end
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: 2, tool: {name: "jobcompat", version: VERSION}, status: status,
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}
@@ -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 class absent from HEAD source",
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
- @names = (base.workers.keys + head.workers.keys).uniq.sort
17
- @calls = {"base" => resolve(base.calls), "head" => resolve(head.calls)}
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" => (base.workers.keys - head.workers.keys).sort,
22
- "base" => (head.workers.keys - base.workers.keys).select { |name| @calls_by_worker["head"].key?(name) }.sort
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.workers[name]
34
- h = head.workers[name]
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 class is absent from HEAD tracked Ruby source.",
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 class until retained jobs can no longer run."],
45
- b.declaration_locations + b.locations.select { |loc| loc.role == "consumer" } + hc.map { |call| call[:location] })
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 class absent from base tracked Ruby source.",
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.map { |call| call[:location] })
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.map { |call| call[:location] } + b.locations.select { |loc| loc.role == "consumer" } + h.locations.select { |loc| loc.role == "consumer" } + head_same.map { |call| call[:location] }
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.map { |call| call[:location] } + h.locations.select { |loc| loc.role == "consumer" })
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.map { |call| call[:location] } + b.locations.select { |loc| loc.role == "consumer" } + h.locations.select { |loc| loc.role == "consumer" })
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.locations.select { |loc| loc.role == "consumer" } + h.locations.select { |loc| loc.role == "consumer" })
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(raw_calls)
126
- raw_calls.sort_by { |call| [call.path, call.location.line, call.location.column, call.method,
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.namespace.reverse.map { |prefix| "#{prefix}::#{call.receiver}" } + [call.receiver]).uniq
143
+ ConstantLookup.candidates(call.receiver, false, call.namespace)
136
144
  end
137
- worker = candidates.find { |name| @names.include?(name) }
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
- reason = call.unknown_reason
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, location: call.location, arity: call.arity, reason: reason, raw: call}
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 "recognized_worker" if contract
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
- snapshot.workers.each do |name, contract|
200
- next if contract.known?
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
- roots << {revision: snapshot.label, kind: kind, reason: call[:reason], worker: call[:worker],
214
- locations: [call[:location]], parts: [kind, call[:reason], call[:worker], raw.fingerprint_parts],
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.workers.key?(first[:worker]) && !base.workers.key?(first[:worker])
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
- "#{first[:reason]} prevents a complete positional compatibility check.",
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,
@@ -1,3 +1,3 @@
1
1
  module Jobcompat
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
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.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - jobcompat contributors
@@ -44,18 +44,23 @@ files:
44
44
  - docs/competitive-analysis.md
45
45
  - docs/implementation-plan-v0.2.1.md
46
46
  - docs/implementation-plan-v0.2.md
47
+ - docs/implementation-plan-v0.3.md
47
48
  - docs/implementation-plan.md
48
49
  - docs/release-checklist-v0.1.0.md
49
50
  - docs/release-checklist-v0.2.0.md
50
51
  - docs/release-checklist-v0.2.1.md
52
+ - docs/release-checklist-v0.3.0.md
51
53
  - docs/release-notes-v0.1.0.md
52
54
  - docs/release-notes-v0.2.0.md
53
55
  - docs/release-notes-v0.2.1.md
56
+ - docs/release-notes-v0.3.0.md
54
57
  - docs/spec-v0.1.md
55
58
  - docs/spec-v0.2.1.md
56
59
  - docs/spec-v0.2.md
60
+ - docs/spec-v0.3.md
57
61
  - exe/jobcompat
58
62
  - lib/jobcompat.rb
63
+ - lib/jobcompat/aliases.rb
59
64
  - lib/jobcompat/analysis.rb
60
65
  - lib/jobcompat/canonical_worker_name.rb
61
66
  - lib/jobcompat/cli.rb