jobcompat 0.1.0 → 0.2.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: 0c7ba60ac1a983ea795999572de4a8e670986f6b8ed6d5a05433f21a81ca8d03
4
- data.tar.gz: ba82835508ec2d1e056eeb71a94ec51af5cf5696aa53046b6912c50281640221
3
+ metadata.gz: 89c7b2c39f3fbd8e9e683deecc9a5edf2021f6b3ba9b60350635519621b28048
4
+ data.tar.gz: c6beb6202b88b1bfcc4536d83479c82005e45d920d555ec5f3645e626e5d2aa2
5
5
  SHA512:
6
- metadata.gz: bc94f38e9c059633c5701bc11982298f0d4786b8b58ff11494b0233a6de9f8f5a11e7e3e05ddae563a16773094be3ff410eff881e319cd3b1d8d8a0b336518a1
7
- data.tar.gz: bb16e4959a69dde9a1aac0fbaff894aa3291009d538927a83a721d7a64bfee67ef804a978d5044ed7c4f7ed8fda66aa09b4a7a8caccf2da1bb1b25a44a5c89c3
6
+ metadata.gz: c730b84dc08928632344fa3e539db97c7a723356b19ba3e89a2c3f9efc0c40979c4e9ab26bf9ff6ad92c1c8beefda83e519c57eca7cde7230c169d8f5e31df4c
7
+ data.tar.gz: 0d79be4a2b98032937d8f7c926722ac6a57ff4f899d408a17ecfdd64bb702e823e624d367a2921b3b251b21dff80cb7bf1394f45c322f3f588f5987c6c86824b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-10-02
4
+
5
+ Broader native Sidekiq producer coverage:
6
+
7
+ - Recognize `perform_bulk`, Setter `perform_in` / `perform_at` / `perform_bulk`, and `Sidekiq::Client.push` / `push_bulk`.
8
+ - Normalize mixed bulk arities into distinct producer facts while retaining unknown rows.
9
+ - Emit JSON schema version 2 with producer uncertainty reasons for bulk and Client payloads.
10
+ - Resolve Client Hash splats and dynamic keys per key in source order, preserving known workers when only `"args"` is uncertain and preventing false JC005 when a later entry may replace `"class"`.
11
+ - Validate Client class Strings with Prism's Ruby constant syntax, preserving Unicode identities and reporting malformed or invalid-byte names as JC007.
12
+
3
13
  ## 0.1.0 - 2026-09-26
4
14
 
5
15
  - Extract positional `perform` contracts from native `Sidekiq::Job` and `Sidekiq::Worker` classes, including reopened classes.
data/README.md CHANGED
@@ -27,7 +27,7 @@ Sidekiq persists a worker `class` and an `args` array. The worker receives those
27
27
 
28
28
  ## Installation
29
29
 
30
- Ruby 3.3 or newer is required. After release, install with `gem install jobcompat`, or add `gem "jobcompat", require: false` to your Gemfile, run `bundle install`, and use `bundle exec jobcompat`. `prism` is the only runtime dependency; jobcompat does not require the Sidekiq gem to inspect source.
30
+ Ruby 3.3 or newer is required. Install with `gem install jobcompat`, or add `gem "jobcompat", require: false` to your Gemfile, run `bundle install`, and use `bundle exec jobcompat`. `prism` is the only runtime dependency; jobcompat does not require the Sidekiq gem to inspect source.
31
31
 
32
32
  ## Quick Start
33
33
 
@@ -54,11 +54,11 @@ 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: 1` 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: 2` and includes per-worker compatibility matrices.
58
58
 
59
59
  ## How it works
60
60
 
61
- jobcompat reads committed Ruby blobs through Git, parses each selected file with Prism, groups reopened class fragments by canonical name, discovers direct Sidekiq includes and enqueue calls, then compares positional-arity intervals. Application code is never loaded or executed.
61
+ jobcompat reads committed Ruby blobs through Git, parses each selected file with Prism, groups reopened class fragments by canonical name, normalizes supported Sidekiq producer syntax into worker/arity facts, then compares positional-arity intervals. Application code is never loaded or executed.
62
62
 
63
63
  Its separate `DefinedConstantIndex` checks tracked Ruby class declarations and named bindings before declaring a worker class absent. A class that remains in an excluded file or stops using a direct Sidekiq include produces a warning rather than a removal error.
64
64
 
@@ -119,13 +119,30 @@ Run `jobcompat check --base origin/main --format json` after fetching the compar
119
119
 
120
120
  ## Supported Sidekiq patterns
121
121
 
122
- Direct `include Sidekiq::Job` and legacy `include Sidekiq::Worker` are recognized in top-level and supported namespaced classes. Reopened fragments may split the include and `perform` across files. The producer forms are `Job.perform_async(...)`, `Job.perform_in(schedule, ...)`, `Job.perform_at(time, ...)`, and `Job.set(...).perform_async(...)`. Hash and Array expressions each count as one payload argument. A splat or forwarded producer argument makes arity unknown.
122
+ Direct `include Sidekiq::Job` and legacy `include Sidekiq::Worker` are recognized in top-level and supported namespaced classes. Reopened fragments may split the include and `perform` across files. Supported producer APIs are:
123
123
 
124
- Positional `perform` parameters may be required, optional, rest, post-rest, or forwarding. Keyword parameters make the worker contract unknown for v0.1.
124
+ - `Job.perform_async(...)`, `Job.perform_in(schedule, ...)`, `Job.perform_at(time, ...)`, and `Job.perform_bulk(...)`;
125
+ - `Job.set(...).perform_async(...)`, `.perform_in(...)`, `.perform_at(...)`, and `.perform_bulk(...)`;
126
+ - `Sidekiq::Client.push(...)` and `Sidekiq::Client.push_bulk(...)` with a static Hash payload using string `"class"` and `"args"` keys.
125
127
 
126
- ## Limitations and v0.1 non-goals
128
+ Client Hash entries are resolved per key in source order. A later explicit `"class"` or `"args"` overrides earlier writes; a later unknown Hash splat or dynamic key makes only the keys it may replace uncertain. Static unrelated keys do not affect certainty. A statically known worker is preserved when only args may be replaced; an uncertain class is not attributed to an earlier worker and cannot produce JC005 for it.
127
129
 
128
- v0.1 checks **positional arity only**. It does not check value types, Hash internals, keyword compatibility, or JSON serialization. It supports native Sidekiq, not ActiveJob. Only direct includes and the documented direct producer calls are analyzed. `Sidekiq::Client.push`, bulk APIs, wrappers, aliases, inheritance, concerns, arbitrary metaprogramming, feature flags, and general Ruby class/module or superclass conflict analysis are outside scope.
130
+ Hash and Array expressions each count as one ordinary payload argument. Bulk calls produce one fact per distinct statically known inner Array arity:
131
+
132
+ ```ruby
133
+ ExportJob.perform_bulk([
134
+ [1],
135
+ [2, "csv"]
136
+ ])
137
+ ```
138
+
139
+ jobcompat sees payload arities 1 and 2. A dynamic bulk row is retained as `dynamic_bulk_arguments` uncertainty without discarding known rows from the same callsite. A Client class String is an exact canonical Ruby constant name; it is never resolved relative to the surrounding Ruby namespace or Unicode-normalized. Malformed names produce JC007 warnings.
140
+
141
+ Positional `perform` parameters may be required, optional, rest, post-rest, or forwarding. Keyword parameters make the worker contract unknown in v0.2.
142
+
143
+ ## Limitations and v0.2 non-goals
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.
129
146
 
130
147
  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.
131
148
 
@@ -150,11 +167,11 @@ Analysis reads local Git objects only. It does not connect to Redis, boot Rails,
150
167
 
151
168
  ## Roadmap
152
169
 
153
- Possible future work includes additional native Sidekiq producer APIs and SARIF output. Other frameworks would be considered only after v0.1 usage evidence; they are not supported now.
170
+ Possible future work includes SARIF output and additional evidence sources. Other frameworks are not supported now.
154
171
 
155
172
  ## Contributing
156
173
 
157
- 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. See [the normative v0.1 spec](docs/spec-v0.1.md) before changing rule behavior.
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.
158
175
 
159
176
  ## License
160
177
 
data/docs/architecture.md CHANGED
@@ -1,7 +1,7 @@
1
- # jobcompat v0.1 architecture
1
+ # jobcompat v0.2 architecture
2
2
 
3
- Status: implementation design for `0.1.0`
4
- Date: 2026-09-23
3
+ Status: implemented design for `0.2.0`
4
+ Date: 2026-09-28
5
5
 
6
6
  ## 1. Architecture principles
7
7
 
@@ -9,7 +9,7 @@ Date: 2026-09-23
9
9
  2. **No application execution:** parse source; never require, boot, or evaluate it.
10
10
  3. **Extraction before semantics:** AST visitors produce facts; a pure engine compares facts.
11
11
  4. **Conservative unknowns:** unsupported evidence is explicit and cannot become a pass.
12
- 5. **Small v0.1 surface:** one framework adapter, one parser, one command, two formatters.
12
+ 5. **Small native Sidekiq surface:** one framework adapter, one parser, one command, two formatters.
13
13
  6. **Determinism:** stable sorting and fully resolved commit SHAs are part of correctness.
14
14
  7. **No speculative framework abstraction:** namespaces organize current responsibilities; no generic plugin API ships in v0.1.
15
15
 
@@ -187,10 +187,12 @@ It receives source bytes and a revision/path context. It does not open files, in
187
187
 
188
188
  `WorkerDiscoveryVisitor` owns class/module namespace tracking, direct Sidekiq includes, and direct `perform` definitions.
189
189
 
190
- `ProducerDiscoveryVisitor` owns calls named `perform_async`, `perform_in`, and `perform_at`, supported `.set` chains, syntactic payload counts, lexical namespace capture, and unknown call facts.
190
+ `ProducerDiscoveryVisitor` owns supported worker calls (`perform_async`, `perform_in`, `perform_at`, `perform_bulk`), supported `.set` chains, and exact static `Sidekiq::Client.push` / `push_bulk` calls. Syntax-specific extraction produces normalized producer facts before the compatibility engine. Bulk Array literals become one fact per distinct known inner arity plus at most the required unknown evidence; empty bulk Arrays produce no facts. Client class constants use lexical resolution, while static class Strings use exact canonical resolution. Client Hash entries are ordered evidence: for `"class"` and `"args"` independently, a shared right-to-left scan stops at the first entry capable of writing the key. An explicit string key proves its value; an unknown Hash splat or dynamic/non-static key prevents that proof; static unrelated keys are skipped. Known class evidence remains usable when only args are uncertain, while uncertain class evidence carries no worker attribution into the engine.
191
191
 
192
192
  `ConstantName` is a small utility that flattens only `ConstantReadNode` and `ConstantPathNode`. It returns a value object with `segments`, `root_qualified`, and `static`; it does not resolve Ruby constants.
193
193
 
194
+ Client String targets use one pure analysis helper shared by `push` and `push_bulk`. After safe UTF-8 conversion and encoding validation, Prism parses the value in isolation. Only a single `ConstantReadNode` or static `ConstantPathNode` is accepted, using the existing constant extractor. Root-qualified Strings are rejected, and the extracted canonical name must equal the complete original text, excluding whitespace, comments, and extra syntax. This preserves Ruby's Unicode identifiers without normalization or lexical prefixing. Expected encoding failures and invalid syntax become `unsupported_client_payload`; unexpected parser failures remain tool failures. No source is executed.
195
+
194
196
  `DefinedConstantIndex` records exact or plausible canonical class/module declarations and statically named constant bindings, source locations, and normal-scan membership. It is distinct from supported Sidekiq worker recognition. For a name missing from one revision's recognized workers, it returns `recognized_worker`, `defined_unrecognized`, `outside_scan_scope`, `absent`, or `unverified` using the formal proof in the spec. A present-but-unrecognized class or incomplete presence pass blocks JC004/JC005 ERROR.
195
197
 
196
198
  `SemanticEvidence` builds the revision-free JC007 fingerprint from uncertainty kind/reason, canonical worker when known, sorted relative paths, syntactic enclosing scope, trivia-free relevant token stream and enclosing statement, plus deterministic group size and occurrence ordinal. It retains every revision-tagged root location. Same-fingerprint roots pair one-to-one across snapshots; distinct roots remain separate.
@@ -212,7 +214,7 @@ The engine MUST be pure with respect to filesystem, Git, environment, clock, and
212
214
  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.
213
215
 
214
216
  - `Formatter::Text` renders concise human remediation.
215
- - `Formatter::Json` constructs schema-version-1 primitive Hash/Array data, including 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. The formal specification fixes two-space pretty JSON; exact-output tests lock it.
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.
216
218
 
217
219
  Both consume already sorted results.
218
220
 
@@ -317,6 +319,8 @@ Presence-only blobs reuse this parser with a narrower visitor. A candidate-beari
317
319
  | keywords | `ParametersNode#keywords/#keyword_rest` | unsupported status, except forwarding node |
318
320
  | forwarding | `ForwardingParameterNode` | unbounded positional acceptance |
319
321
  | enqueue call | `CallNode#name/#receiver/#arguments/#block` | method, receiver fact, argument nodes, location |
322
+ | bulk rows | `ArrayNode#elements`, `SplatNode` | distinct known inner arities and dynamic-row evidence |
323
+ | Client payload | `HashNode` / `KeywordHashNode`, `AssocNode`, `AssocSplatNode` | exact string keys, worker target mode, args shape, dynamic payload evidence |
320
324
  | safe navigation | `CallNode#call_operator_loc` whose source slice is `&.` | unknown receiver evidence |
321
325
  | call arguments | `ArgumentsNode#arguments` | syntactic count |
322
326
  | splat | `SplatNode` | unknown producer arity |
@@ -354,12 +358,13 @@ UnresolvedEnqueueCall
354
358
  lexical_namespace Array<String>
355
359
  payload_arity Integer | nil
356
360
  arity_known true | false
357
- method perform_async | perform_in | perform_at
361
+ method perform_async | perform_in | perform_at | perform_bulk | push | push_bulk
362
+ resolution_mode lexical_constant | client_constant | exact_string
358
363
  location
359
364
  unknown_reason Symbol | nil
360
365
  ```
361
366
 
362
- The symbol values map one-to-one to the schema-v1 strings enumerated in `docs/spec-v0.1.md`; visitors do not invent free-form reason text.
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.
363
368
 
364
369
  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.
365
370
 
@@ -410,7 +415,7 @@ The JSON formatter serializes both classes as a contract object with `status: kn
410
415
  worker_name : String | nil
411
416
  payload_arity : Integer | nil
412
417
  arity_known : Boolean
413
- method : :perform_async | :perform_in | :perform_at
418
+ method : :perform_async | :perform_in | :perform_at | :perform_bulk | :push | :push_bulk
414
419
  location : SourceLocation
415
420
  unknown_reason : Symbol | nil
416
421
  ```
@@ -565,7 +570,7 @@ The v0.1 security posture is a product characteristic:
565
570
 
566
571
  Threat boundary: the repository and config may be untrusted input. Prism and Git process those bytes, but jobcompat never evaluates Ruby. JSON/text escaping MUST prevent control characters in paths/excerpts from corrupting output framing. Text formatter should escape non-printable path characters; JSON handles them through the JSON encoder.
567
572
 
568
- ## 14. Future extension points without v0.1 over-engineering
573
+ ## 14. Future extension points without over-engineering
569
574
 
570
575
  The following seams are sufficient:
571
576
 
@@ -578,10 +583,10 @@ The following seams are sufficient:
578
583
 
579
584
  Potential roadmap order:
580
585
 
581
- 1. more native Sidekiq producer forms (`Client.push`, bulk) after precision research;
586
+ 1. more native Sidekiq producer forms only after precision research;
582
587
  2. SARIF/GitHub integration;
583
588
  3. optional changed-path performance hints without weakening whole-snapshot facts;
584
589
  4. ActiveJob as a separately specified adapter;
585
590
  5. other ecosystems only after defining their serialization/deployment contracts.
586
591
 
587
- Do not add an adapter registry, dependency injection container, generic AST facade, or rule plugin system in v0.1.
592
+ Do not add an adapter registry, dependency injection container, generic AST facade, or rule plugin system without a concrete second implementation.
@@ -0,0 +1,29 @@
1
+ # jobcompat v0.2 implementation plan
2
+
3
+ Status: implemented
4
+
5
+ ## Delivery sequence
6
+
7
+ 1. Reconfirm the public Sidekiq Job/Setter and Client producer shapes from upstream source and documentation.
8
+ 2. Extend syntax extraction while keeping the existing `Call` fact and compatibility engine semantics.
9
+ 3. Share one pure bulk-arity extractor across worker, Setter, and Client bulk calls.
10
+ 4. Add exact-string versus lexical-constant Client target resolution and explicit v2 uncertainty reasons.
11
+ 5. Raise the gem version to 0.2.0 and JSON schema to 2 while leaving config version 1.
12
+ 6. Add unit coverage for AST shapes, deduplication, mixed known/unknown rows, options, false positives, and 1,000-row fact bounds.
13
+ 7. Add temporary-Git integration coverage for JC001, JC002, JC003, JC005, JC006 witness behavior, JC007, schema v2, and byte determinism.
14
+ 8. Synchronize README, architecture, changelog, and this normative delta specification.
15
+ 9. Run the full suite, syntax checks, gem build/contents/install, CLI and exit-code smoke scenarios, and diff review.
16
+
17
+ ## Scope boundaries
18
+
19
+ The compatibility rules and their precedence are not changed. No Sidekiq dependency, dataflow, source execution, Client instance inference, wrapper inference, ActiveJob support, keyword compatibility, payload type/schema analysis, or release operation is added.
20
+
21
+ ## Verification inventory
22
+
23
+ - Existing v0.1 suite remains green.
24
+ - Bulk extraction covers known, duplicate, zero-argument, empty, dynamic, mixed, splat, option, namespace, root-qualified, safe-navigation, and large-literal cases.
25
+ - Setter scheduled calls exclude their schedule argument.
26
+ - Client tests cover exact receivers, constant and String classes, empty and multi-argument payloads, options, dynamic/missing shapes, per-key final-write certainty for splats and dynamic keys, duplicate explicit keys, mixed splat/dynamic ordering, parentheses, and non-Sidekiq receivers. BASE/HEAD guards prove unknown class evidence cannot create JC005, while known class / unknown args still can, for both `push` and `push_bulk`.
27
+ - Client String validation reuses Prism's constant syntax and the existing constant extractor. Tests cover decomposed Unicode and qualified names, exact namespace matching, distinct precomposed/decomposed identities, non-canonical complete inputs, valid source encodings, and invalid bytes that warn without crashing. Both Client APIs exercise Unicode JC001/JC002/JC003/JC005 and deterministic Unicode/unsupported output.
28
+ - Integration tests prove new producer facts flow through JC001–JC007 without a new rule.
29
+ - Repeated text and JSON outputs are byte-identical.
@@ -1,6 +1,6 @@
1
1
  # jobcompat v0.1.0 release checklist
2
2
 
3
- This checklist is for the future gem release. The public repository is `https://github.com/cottondesu/jobcompat`; initial publication of `main` is authorized, but tagging, publishing a gem, and creating a GitHub Release are reserved for the release phase. Gem version `0.1.0` uses tag `v0.1.0`; JSON `schema_version: 1` is a separate contract.
3
+ This checklist records preparation for the published v0.1.0 gem release. The public repository is `https://github.com/cottondesu/jobcompat`; gem version `0.1.0` uses tag `v0.1.0`; JSON `schema_version: 1` is a separate contract. Unchecked items remain external follow-ups and are not assertions that their gates were completed.
4
4
 
5
5
  ## Local, machine executable checks
6
6
 
@@ -13,7 +13,7 @@ This checklist is for the future gem release. The public repository is `https://
13
13
  - [x] Repeat the secret and personal path scan; review README, CHANGELOG, LICENSE, release notes, and workflow against the built gem.
14
14
  - [x] Verify every GitHub Action reference is a pinned commit SHA and inspect the diff before the initial commit.
15
15
 
16
- ## Manual gates before publication
16
+ ## Pre-publication manual gates
17
17
 
18
18
  - [x] Create the public [`cottondesu/jobcompat`](https://github.com/cottondesu/jobcompat) repository with default branch `main`.
19
19
  - [x] Set the real repository URL in gemspec `homepage`, `source_code_uri`, `changelog_uri`, and `bug_tracker_uri`.
@@ -21,12 +21,12 @@ This checklist is for the future gem release. The public repository is `https://
21
21
  - [ ] Configure a `main` ruleset or branch protection that blocks force pushes and deletion and requires successful CI; avoid unnecessary review gates for a single maintainer.
22
22
  - [ ] Confirm GitHub two factor authentication and RubyGems MFA.
23
23
  - [ ] Protect the GitHub `release` environment with appropriate approval or deployment restrictions.
24
- - [ ] Confirm Ruby 3.3, 3.4, and 4.0 GitHub CI jobs all pass on the exact release candidate. Ruby 3.3 CI is mandatory because the local runtime has not been tested.
24
+ - [ ] Confirm Ruby 3.3, 3.4, and 4.0 GitHub CI jobs all pass on the exact release candidate. Ruby 3.3 CI was mandatory because Ruby 3.3 was not tested locally during release preparation.
25
25
  - [ ] Configure a RubyGems [Pending Trusted Publisher](https://guides.rubygems.org/trusted-publishing/) for the new `jobcompat` gem using GitHub owner `cottondesu`, repository `jobcompat`, workflow file `release.yml`, and environment `release`. Confirm the details match the release workflow. Do not store a long lived RubyGems API key in GitHub Secrets.
26
26
  - [ ] Recheck that `jobcompat` is still unregistered on RubyGems immediately before the actual gem publication.
27
27
  - [ ] Review the tag triggered release workflow and built gem one final time before pushing `v0.1.0`.
28
28
 
29
- ## Exact publication sequence (future manual actions)
29
+ ## Historical publication sequence
30
30
 
31
31
  1. Recheck gem name before every public push and again immediately before gem publication.
32
32
  2. Rerun local tests, build, install, smoke checks, package review, and secret scan. Resolve every build warning.
@@ -0,0 +1,61 @@
1
+ # jobcompat v0.2.0 release checklist
2
+
3
+ This is preparation for an update to the already published `jobcompat` gem. Checked boxes record completed preparation checks; unchecked boxes are final release or post-publication gates, not claims of completion. No v0.2.0 tag or publication is authorized by this document.
4
+
5
+ ## Release identity
6
+
7
+ - Repository: `cottondesu/jobcompat`, branch `main`.
8
+ - Gem version: `0.2.0`; intended annotated tag: `v0.2.0`.
9
+ - Intended release date: `2026-10-02`. If execution is delayed, reconcile the CHANGELOG date with the actual intended date before tagging; never backdate publication.
10
+ - JSON schema: `2`; `.jobcompat.yml` configuration schema: `1`.
11
+ - Implementation commit: `c107c9a9868cf3d29f4b821444fe1e0cfcb86820`.
12
+ - Release candidate commit: to be determined after the `Prepare v0.2.0 release` commit. Record its exact SHA and CI run in the final release execution record; do not select a moving `main` ref without verification.
13
+
14
+ ## Automated validation
15
+
16
+ - [x] Implementation commit passed [CI run 36875203650](https://github.com/cottondesu/jobcompat/actions/runs/36875203650): Ruby 3.3, 3.4, and 4.0 PASS; each ran 122 tests / 902 assertions and built the Gem.
17
+ - [x] Candidate local `bundle exec rake test` on Ruby 3.4.11 / Prism 1.9.0: 122 tests, 902 assertions, zero failures, errors, and skips.
18
+ - [x] Candidate `git diff --check` and release-only diff review pass; product bytes match the implementation commit.
19
+ - [x] Build the candidate Gem and inspect its 24 entries, including both v0.2.0 release documents. No local tooling state, credentials, caches, scratch files, or nested Gems are included.
20
+ - [x] Install the candidate Gem into a fresh isolated destination; version/help, clean 0, compatibility error 1, warning-only 0, invalid ref 2, and config version 1 pass.
21
+ - [ ] Record the pushed release-preparation SHA and its CI run; require Ruby 3.3, 3.4, and 4.0 success on that exact SHA before tagging. The implementation run above does not substitute for candidate CI.
22
+
23
+ ## Publication prerequisites
24
+
25
+ - [x] On 2026-10-02, public RubyGems listed `0.1.0` and no `0.2.0`; public owner handle was `cottondesu`, and metadata pointed to this repository.
26
+ - [x] On 2026-10-02, local/remote tags and GitHub Releases contained `v0.1.0` and no `v0.2.0`.
27
+ - [x] GitHub environment `release` exists, requires reviewer `cottondesu`, and permits tag pattern `v*`.
28
+ - [ ] Confirm the current RubyGems Trusted Publisher for `jobcompat` in the maintainer UI: owner `cottondesu`, repository `jobcompat`, workflow file `release.yml`, environment `release`. Public package metadata and earlier publication are not proof of the current publisher settings. See [Trusted Publishing](https://guides.rubygems.org/trusted-publishing/).
29
+ - [x] Release workflow retains tag/version and public metadata gates. Gem homepage, source, changelog, and issue URLs refer to `cottondesu/jobcompat`.
30
+ - [x] Review the v0.2.0 release notes against the normative scope and inspect the built candidate package.
31
+ - [ ] Immediately before release execution, recheck that `0.2.0` is absent on RubyGems and `v0.2.0` is absent locally, remotely, and in GitHub Releases. Stop if any already exists; never overwrite it.
32
+
33
+ ## Tag gate
34
+
35
+ **Pushing `v0.2.0` triggers the Release workflow and may publish the Gem automatically. Treat tag push as a publication action.**
36
+
37
+ - [ ] Complete every pre-tag validation and publication prerequisite above, including the manual Trusted Publisher check and exact candidate CI.
38
+ - [ ] Obtain separate final release execution approval. Release preparation alone does not authorize tagging or publication.
39
+ - [ ] Confirm the exact release candidate SHA, product version, intended release date, and unchanged v0.1.0 release identity.
40
+ - [ ] Under that separate approval, create an annotated `v0.2.0` tag on the verified SHA and push only that tag. Do not force or push unrelated tags.
41
+
42
+ ## Release workflow verification
43
+
44
+ The unchanged `.github/workflows/release.yml` triggers on pushes matching `v*`. Its Ruby 3.3 / 3.4 / 4.0 tests precede the `publish` job through `needs: test`. Publication uses Ruby 4.0, environment `release`, GitHub OIDC `id-token: write`, pinned `rubygems/configure-rubygems-credentials`, and `gem push`.
45
+
46
+ - [ ] Verify the triggered Release run refers to the approved tag and exact candidate SHA.
47
+ - [ ] Confirm all three test jobs pass and the publication environment approval is satisfied.
48
+ - [ ] Confirm `GITHUB_REF_NAME == "v#{Jobcompat::VERSION}"`, public metadata checks, publication-job tests, build, credentials setup, and push all succeed.
49
+ - [ ] Confirm RubyGems lists `jobcompat` version `0.2.0`; do not infer publication solely from a tag or a partial workflow result.
50
+
51
+ ## After publication
52
+
53
+ - [ ] Check RubyGems version, ownership, Ruby/dependency requirements, public URLs, and package contents.
54
+ - [ ] Install `jobcompat` 0.2.0 from RubyGems in a clean environment; verify version/help and representative CLI checks.
55
+ - [ ] Under the separate release approval, create or verify the GitHub Release for `v0.2.0` using the reviewed release notes and correct tag.
56
+ - [ ] Record the release workflow URL and publication evidence; monitor issue/security reports.
57
+ - [ ] Track the pre-existing Unicode suppression validator limitation as a possible v0.2.1 follow-up; do not alter v0.2.0 bytes after publication.
58
+
59
+ ## Rollback
60
+
61
+ Do not overwrite or reuse version `0.2.0` after publication, move the published tag, or alter v0.1.0. Fix forward in `0.2.1`. If necessary, use the official [RubyGems yanking procedure](https://guides.rubygems.org/removing-a-published-gem/) and communicate the affected version; yanking does not remove installed copies.
@@ -0,0 +1,71 @@
1
+ # jobcompat v0.2.0 release notes
2
+
3
+ jobcompat v0.2.0 broadens native Sidekiq producer coverage while preserving JC001–JC007, the three rolling-deployment directions, and positional-arity analysis.
4
+
5
+ ## Highlights
6
+
7
+ - Recognize worker bulk calls, scheduled and bulk Setter calls, and direct static Sidekiq Client payloads.
8
+ - Normalize bulk jobs into distinct arity facts without losing known rows when other rows are unknown.
9
+ - Resolve Client Hash evidence independently for `"class"` and `"args"` in source order.
10
+ - Validate exact String worker names with Prism's Ruby constant syntax, preserving Unicode spelling and handling malformed names conservatively.
11
+ - Emit JSON schema version 2; `.jobcompat.yml` remains version 1.
12
+
13
+ ## New producer coverage
14
+
15
+ The following forms join the existing `perform_async`, `perform_in`, `perform_at`, and `set(...).perform_async` support:
16
+
17
+ - `Worker.perform_bulk(...)`;
18
+ - `Worker.set(...).perform_in(...)` and `.perform_at(...)`;
19
+ - `Worker.set(...).perform_bulk(...)`;
20
+ - `Sidekiq::Client.push(...)` and `.push_bulk(...)`.
21
+
22
+ Setter options and the first schedule argument of `perform_in` / `perform_at` do not count as job payload arguments.
23
+
24
+ ## Bulk behavior
25
+
26
+ ```ruby
27
+ ExportJob.perform_bulk([
28
+ [1],
29
+ [2, "csv"]
30
+ ])
31
+ ```
32
+
33
+ This supplies distinct positional arity facts for 1 and 2. Duplicate arities are collapsed, and an empty bulk Array supplies no producer evidence. Dynamic rows or inner splats remain conservative JC007 evidence without discarding known sibling rows. A dynamic outer collection remains unknown. Bulk calls use the existing compatibility rules; findings still carry one integer or null `payload_arity`.
34
+
35
+ ## Sidekiq::Client support
36
+
37
+ Direct `Sidekiq::Client.push` and `push_bulk` calls accept statically understandable Hash payloads with string `"class"` and `"args"` keys. `push` counts a static args Array; `push_bulk` applies the same bulk normalization as worker calls.
38
+
39
+ For each relevant key, the last entry capable of writing it determines certainty. Later explicit keys can restore certainty after earlier splats or dynamic keys. Later unknown writes prevent proof of the affected key. A known worker with unknown args can still supply JC005 presence evidence and worker-attributed JC007; an uncertain class is not attributed to an earlier worker.
40
+
41
+ Class constants use the existing bounded lexical, qualified, and root-qualified resolution. Class Strings are exact canonical Ruby constant paths accepted by Prism, without leading `::`, namespace prefixing, or Unicode normalization. Malformed syntax and invalid String bytes become unsupported JC007 evidence. Payload variables, general Hash evaluation, Client instances, and wrappers are not inferred. See the [normative v0.2 scope](spec-v0.2.md) for supported options and uncertainty reasons.
42
+
43
+ ## JSON schema 2
44
+
45
+ v0.1 emitted `schema_version: 1`; v0.2 emits `schema_version: 2`. Schema 2 extends producer uncertainty reasons for bulk and Client APIs while preserving the existing output structure, findings, directions, revisions, and locations.
46
+
47
+ The added reasons are `dynamic_bulk_arguments`, `dynamic_bulk_options`, `dynamic_client_payload`, `dynamic_client_class`, `dynamic_client_args`, and `unsupported_client_payload`.
48
+
49
+ The `.jobcompat.yml` configuration schema is separate and remains `version: 1`. Do not change it to version 2.
50
+
51
+ ## Compatibility
52
+
53
+ JC001–JC007 meanings, severity, JC001/JC003 precedence, static-presence proof, and the base-to-head, head-to-base, and head-to-head directions are unchanged. Exit codes remain 0 for no unsuppressed errors, 1 for compatibility errors, and 2 for tool/config/Git/parser/usage failures. Warnings do not establish compatibility for unknown evidence.
54
+
55
+ ## Known limitations
56
+
57
+ ActiveJob, keyword compatibility, payload value types, Hash internal schemas, live Redis inspection, wrappers, aliases, general dataflow, and runtime metaprogramming remain outside scope. An undiscovered producer does not prove that a queue is empty. Analysis does not execute application code.
58
+
59
+ The existing suppression worker-name validator is stricter than producer discovery for some Unicode combining-mark identifiers. Such workers can be analyzed correctly but rejected in `.jobcompat.yml` ignore entries. This pre-existing limitation is a v0.2.1 follow-up candidate; it does not affect general Unicode producer recognition.
60
+
61
+ ## Upgrade notes
62
+
63
+ Update JSON consumers to accept schema version 2 and the additional producer uncertainty reasons. Keep configuration version 1. Broader producer discovery may surface compatibility errors or JC007 warnings that v0.1 did not report; review those findings before deployment.
64
+
65
+ Ruby 3.3 or newer and Prism `>= 1.9, < 2` remain required. No Sidekiq runtime dependency is added.
66
+
67
+ ## Installation
68
+
69
+ After publication, install this version with `gem install jobcompat --version 0.2.0`. Bundler users can specify `gem "jobcompat", "~> 0.2.0", require: false`.
70
+
71
+ Fetch the intended comparison ref, then run `jobcompat check --base origin/main` or `bundle exec jobcompat check --base origin/main` in the application repository.
data/docs/spec-v0.2.md ADDED
@@ -0,0 +1,67 @@
1
+ # jobcompat v0.2 normative delta specification
2
+
3
+ Status: normative for `0.2.0`
4
+
5
+ This document is a delta from [`spec-v0.1.md`](spec-v0.1.md). **v0.1 rules remain normative unless explicitly overridden here.** JC001–JC007, their severity and precedence, the three deployment directions, immutable Git inputs, source-only Prism analysis, and conservative unknown behavior are unchanged.
6
+
7
+ ## 1. Versioned contracts
8
+
9
+ - Gem and CLI version: `0.2.0`.
10
+ - JSON `schema_version`: `2`.
11
+ - `.jobcompat.yml` version: `1`.
12
+ - JSON schema v2 preserves the v1 field structure. Its intentional public expansion is the producer `unknown_reason` vocabulary.
13
+
14
+ ## 2. Supported producer syntax
15
+
16
+ In addition to all v0.1 forms, v0.2 recognizes:
17
+
18
+ - `Worker.perform_bulk(rows, **options)`;
19
+ - `Worker.set(...).perform_in(schedule, *payload)` and `.perform_at(time, *payload)`;
20
+ - `Worker.set(...).perform_bulk(rows, **options)`;
21
+ - `Sidekiq::Client.push(payload)` and `::Sidekiq::Client.push(payload)`;
22
+ - `Sidekiq::Client.push_bulk(payload)` and its root-qualified form.
23
+
24
+ Only the exact static Client receiver is recognized. Client instances, `via`, wrappers, similarly named receivers, ActiveJob, inline/sync execution, and other bulk-like method names remain unsupported or ignored as defined by the v0.1 evidence policy.
25
+
26
+ ## 3. Bulk normalization
27
+
28
+ A static outer Array is inspected without evaluating inner values. Each inner Array with no splat contributes its element count. Duplicate counts are removed and sorted. One callsite therefore emits at most one producer fact per distinct known arity, plus uncertainty evidence. `[]` emits no producer fact and no warning.
29
+
30
+ Non-Array rows and rows containing a splat emit `dynamic_bulk_arguments`; known sibling rows remain known. A dynamic outer collection emits only that unknown evidence. Explicit `at`, `batch_size`, and `spread_interval` options do not contribute to payload arity. A keyword splat that could override bulk class or args emits `dynamic_bulk_options` and does not prove a known payload.
31
+
32
+ The engine receives ordinary single-arity `Call` facts. It has no bulk-specific compatibility rule or arity-set type.
33
+
34
+ ## 4. Setter schedules
35
+
36
+ For Setter `perform_in` and `perform_at`, the first outer call argument is the schedule and is excluded from payload arity. Setter options are not payload arguments. Existing Setter `perform_async` behavior is unchanged.
37
+
38
+ ## 5. Static Client payloads
39
+
40
+ `push` requires one static Hash payload with runtime-valid string `"class"` and `"args"` keys. `"args"` must be a static Array with no splat; its length is the payload arity. Additional valid string job options do not affect arity.
41
+
42
+ `push_bulk` uses the same class extraction and bulk normalization for `"args"`. The documented symbol forms of `at`, `batch_size`, and `spread_interval` are accepted because Sidekiq consumes them before normalizing job keys; other non-string keys are unsupported.
43
+
44
+ A class constant uses the existing root/qualified/lexical worker resolution. A class String must be a canonical Ruby constant path accepted by the supported Prism parser, without leading `::`, and is matched exactly against the base/head worker union. Surrounding namespaces are never prefixed, and Unicode identifiers are not normalized. Both Client APIs share this validation: safely convert the value to valid UTF-8, parse exactly one static constant expression, reuse constant-path extraction, and require the extracted canonical name to equal the complete String value. Malformed bytes, parser diagnostics, whitespace, comments, extra statements, and other non-canonical syntax produce `unsupported_client_payload` JC007 evidence rather than an internal error.
45
+
46
+ For `"class"` and `"args"` independently, the last Hash entry capable of writing that key determines certainty. A later explicit string key replaces earlier writes; a later unknown Hash splat or dynamic/non-static key makes that key uncertain. Static unrelated keys cannot overwrite it. Parentheses around a single static expression preserve its evidence. Unsupported static non-string keys retain the payload policy above.
47
+
48
+ A known worker target is retained when only args are uncertain, so JC005 and worker-attributed JC007 can coexist. If class is uncertain, an earlier explicit worker is not retained and cannot create a class-presence request or JC005. A dynamic key that prevents proving either relevant value emits `unsupported_client_payload`; a dynamic key before both final explicit keys does not discard their known evidence.
49
+
50
+ ## 6. Producer uncertainty reasons
51
+
52
+ Schema v2 adds these deterministic reasons:
53
+
54
+ - `dynamic_bulk_arguments`;
55
+ - `dynamic_bulk_options`;
56
+ - `dynamic_client_payload`;
57
+ - `dynamic_client_class`;
58
+ - `dynamic_client_args`;
59
+ - `unsupported_client_payload`.
60
+
61
+ Recognized but unprovable producer evidence maps to JC007. Known and unknown evidence from one bulk expression may coexist, as may ERROR and WARNING findings. Revision is excluded from the semantic evidence fingerprint exactly as in v0.1.
62
+
63
+ ## 7. Determinism and limits
64
+
65
+ Facts sharing one path/line/column are totally ordered by method, arity, and uncertainty reason. Producer arity arrays remain sorted unique integers; finding `payload_arity` remains an integer or null. No source is evaluated, required, or booted; there is no local-variable dataflow, Redis access, Sidekiq runtime dependency, checkout, or working-tree mutation.
66
+
67
+ The Prism constraint remains `>= 1.9, < 2`, and Ruby remains `>= 3.3`.
@@ -18,7 +18,7 @@ module Jobcompat
18
18
  signature_kind: signature_kind, signature: signature, unknown_reason: unknown_reason}
19
19
  end
20
20
  end
21
- Call = Data.define(:revision, :path, :location, :receiver, :root, :namespace, :scope, :method, :arity, :unknown_reason, :fingerprint_parts)
21
+ Call = Data.define(:revision, :path, :location, :receiver, :resolution_mode, :root, :namespace, :scope, :method, :arity, :unknown_reason, :fingerprint_parts)
22
22
  Unknown = Data.define(:revision, :kind, :reason, :worker, :locations, :fingerprint_parts)
23
23
  Fragment = Data.define(:name, :declaration, :includes, :include_tokens, :performs, :lexical_scope)
24
24
 
@@ -126,6 +126,7 @@ module Jobcompat
126
126
  end
127
127
 
128
128
  def self.constant(node)
129
+ node = unwrap_parentheses(node)
129
130
  case node
130
131
  when Prism::ConstantReadNode then [[node.name.to_s.encode(Encoding::UTF_8)], false]
131
132
  when Prism::ConstantPathNode, Prism::ConstantPathTargetNode
@@ -138,6 +139,13 @@ module Jobcompat
138
139
  end
139
140
  end
140
141
 
142
+ def self.unwrap_parentheses(node)
143
+ while node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode) && node.body.body.one?
144
+ node = node.body.body.first
145
+ end
146
+ node
147
+ end
148
+
141
149
  def self.normalize(source)
142
150
  Prism.lex(source).value.filter_map do |pair|
143
151
  token = pair.first
@@ -237,14 +245,21 @@ module Jobcompat
237
245
 
238
246
  def extract_call(node, namespace, scope, statement)
239
247
  method = node.name.to_s
240
- return unless %w[perform_async perform_in perform_at].include?(method)
241
- return if node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :set && method != "perform_async"
242
- receiver = node.receiver
243
- set_call = receiver if receiver.is_a?(Prism::CallNode) && receiver.name == :set && method == "perform_async"
248
+ if %w[push push_bulk].include?(method) && sidekiq_client?(node.receiver)
249
+ extract_client_call(node, namespace, scope, statement, method)
250
+ return
251
+ end
252
+ return unless %w[perform_async perform_in perform_at perform_bulk].include?(method)
253
+ receiver = self.class.unwrap_parentheses(node.receiver)
254
+ set_call = receiver if receiver.is_a?(Prism::CallNode) && receiver.name == :set
244
255
  receiver = set_call.receiver if set_call
245
256
  constant = self.class.constant(receiver)
246
257
  safe = node.call_operator_loc&.slice == "&." || set_call&.call_operator_loc&.slice == "&."
247
258
  arguments = node.arguments&.arguments || []
259
+ if method == "perform_bulk"
260
+ extract_worker_bulk(node, namespace, scope, statement, method, receiver, constant, safe, arguments)
261
+ return
262
+ end
248
263
  reason = if arguments.any? { |arg| arg.is_a?(Prism::SplatNode) }
249
264
  "splat_arguments"
250
265
  elsif arguments.any? { |arg| arg.is_a?(Prism::ForwardingArgumentsNode) }
@@ -259,9 +274,145 @@ module Jobcompat
259
274
  "safe_navigation_receiver"
260
275
  end
261
276
  arity = reason && %w[splat_arguments forwarded_arguments missing_schedule_argument].include?(reason) ? nil : arguments.length - (method == "perform_async" ? 0 : 1)
277
+ emit_call(node, namespace, scope, statement, method, constant&.first&.join("::"), "lexical_constant", constant&.last, arity, reason)
278
+ end
279
+
280
+ def extract_worker_bulk(node, namespace, scope, statement, method, receiver_node, constant, safe, arguments)
281
+ reason = if safe
282
+ "safe_navigation_receiver"
283
+ elsif constant.nil? && !receiver_node.is_a?(Prism::ConstantPathNode)
284
+ "dynamic_receiver"
285
+ elsif constant.nil?
286
+ "unsupported_constant_path"
287
+ elsif arguments.empty?
288
+ "dynamic_bulk_arguments"
289
+ elsif arguments.drop(1).any? { |arg| !arg.is_a?(Prism::KeywordHashNode) || arg.elements.any? { |element| element.is_a?(Prism::AssocSplatNode) } }
290
+ "dynamic_bulk_options"
291
+ end
292
+ receiver = constant&.first&.join("::")
293
+ if reason
294
+ emit_call(node, namespace, scope, statement, method, receiver, "lexical_constant", constant&.last, nil, reason)
295
+ return
296
+ end
297
+ arities, unknown = extract_bulk_arities(arguments.first)
298
+ arities.each do |arity|
299
+ emit_call(node, namespace, scope, statement, method, receiver, "lexical_constant", constant.last, arity, nil)
300
+ end
301
+ if unknown
302
+ emit_call(node, namespace, scope, statement, method, receiver, "lexical_constant", constant.last, nil, "dynamic_bulk_arguments")
303
+ end
304
+ end
305
+
306
+ def extract_client_call(node, namespace, scope, statement, method)
307
+ if node.call_operator_loc&.slice == "&."
308
+ emit_call(node, namespace, scope, statement, method, nil, nil, nil, nil, "safe_navigation_receiver")
309
+ return
310
+ end
311
+ arguments = node.arguments&.arguments || []
312
+ payload = arguments.one? ? self.class.unwrap_parentheses(arguments.first) : nil
313
+ unless payload.is_a?(Prism::HashNode) || payload.is_a?(Prism::KeywordHashNode)
314
+ emit_call(node, namespace, scope, statement, method, nil, nil, nil, nil, "dynamic_client_payload")
315
+ return
316
+ end
317
+ associations = payload.elements.select { |element| element.is_a?(Prism::AssocNode) }
318
+ class_node, class_status = client_hash_value(payload.elements, "class")
319
+ args_node, args_status = client_hash_value(payload.elements, "args")
320
+ args_node = self.class.unwrap_parentheses(args_node)
321
+ invalid_keys = associations.any? do |association|
322
+ key = self.class.unwrap_parentheses(association.key)
323
+ next false if key.is_a?(Prism::StringNode)
324
+ next false unless key.static_literal?
325
+ method != "push_bulk" || !key.is_a?(Prism::SymbolNode) || !%w[at batch_size spread_interval].include?(key.unescaped)
326
+ end
327
+ reason = "unsupported_client_payload" if [class_status, args_status].any? { |status| %i[missing unsupported].include?(status) } || invalid_keys
328
+ reason ||= "dynamic_client_class" if class_status == :unknown
329
+ receiver, mode, rooted, class_reason = client_worker(class_node)
330
+ reason ||= class_reason
331
+ reason ||= method == "push" ? "dynamic_client_args" : "dynamic_bulk_arguments" if args_status == :unknown
332
+ if reason
333
+ emit_call(node, namespace, scope, statement, method, receiver, mode, rooted, nil, reason)
334
+ return
335
+ end
336
+ if method == "push"
337
+ unless args_node.is_a?(Prism::ArrayNode) && args_node.elements.none? { |element| element.is_a?(Prism::SplatNode) }
338
+ emit_call(node, namespace, scope, statement, method, receiver, mode, rooted, nil, "dynamic_client_args")
339
+ return
340
+ end
341
+ emit_call(node, namespace, scope, statement, method, receiver, mode, rooted, args_node.elements.length, nil)
342
+ else
343
+ arities, unknown = extract_bulk_arities(args_node)
344
+ arities.each { |arity| emit_call(node, namespace, scope, statement, method, receiver, mode, rooted, arity, nil) }
345
+ if unknown
346
+ emit_call(node, namespace, scope, statement, method, receiver, mode, rooted, nil, "dynamic_bulk_arguments")
347
+ end
348
+ end
349
+ end
350
+
351
+ def extract_bulk_arities(node)
352
+ node = self.class.unwrap_parentheses(node)
353
+ return [[], true] unless node.is_a?(Prism::ArrayNode)
354
+ arities = []
355
+ unknown = false
356
+ node.elements.each do |element|
357
+ element = self.class.unwrap_parentheses(element)
358
+ if element.is_a?(Prism::ArrayNode) && element.elements.none? { |item| item.is_a?(Prism::SplatNode) }
359
+ arities << element.elements.length
360
+ else
361
+ unknown = true
362
+ end
363
+ end
364
+ [arities.uniq.sort, unknown]
365
+ end
366
+
367
+ def sidekiq_client?(node)
368
+ constant = self.class.constant(node)
369
+ constant && constant.first == %w[Sidekiq Client]
370
+ end
371
+
372
+ def client_hash_value(elements, key)
373
+ elements.reverse_each do |element|
374
+ return [nil, :unknown] if element.is_a?(Prism::AssocSplatNode)
375
+ entry_key = self.class.unwrap_parentheses(element.key)
376
+ if entry_key.is_a?(Prism::StringNode)
377
+ return [element.value, :known] if entry_key.unescaped == key
378
+ elsif !entry_key.static_literal?
379
+ return [nil, :unsupported]
380
+ end
381
+ end
382
+ [nil, :missing]
383
+ end
384
+
385
+ def client_worker(node)
386
+ node = self.class.unwrap_parentheses(node)
387
+ constant = self.class.constant(node)
388
+ return [constant.first.join("::"), "client_constant", constant.last, nil] if constant
389
+ if node.is_a?(Prism::StringNode)
390
+ name = canonical_client_string_worker_name(node.unescaped)
391
+ return [name, "exact_string", false, nil] if name
392
+ return [nil, nil, nil, "unsupported_client_payload"]
393
+ end
394
+ [nil, nil, nil, "dynamic_client_class"]
395
+ end
396
+
397
+ def canonical_client_string_worker_name(value)
398
+ name = value.encode(Encoding::UTF_8)
399
+ return unless name.valid_encoding?
400
+ result = Prism.parse(name)
401
+ return unless result.errors.empty? && result.value.statements.body.one?
402
+ node = result.value.statements.body.first
403
+ return unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
404
+ constant = self.class.constant(node)
405
+ return unless constant && !constant.last
406
+ canonical_name = constant.first.join("::")
407
+ canonical_name if canonical_name == name
408
+ rescue EncodingError
409
+ nil
410
+ end
411
+
412
+ def emit_call(node, namespace, scope, statement, method, receiver, resolution_mode, root, arity, reason)
262
413
  expression = self.class.normalize(node.location.slice)
263
414
  enclosing = self.class.normalize((statement || node).location.slice)
264
- @calls << Call.new(@revision, @path, loc(node, reason ? "unknown_call" : "producer"), constant&.first&.join("::"), constant&.last,
415
+ @calls << Call.new(@revision, @path, loc(node, reason ? "unknown_call" : "producer"), receiver, resolution_mode, root,
265
416
  namespace, scope, method, arity, reason, [@path, scope, expression, enclosing])
266
417
  end
267
418
 
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: 1, tool: {name: "jobcompat", version: VERSION}, status: status,
101
+ {schema_version: 2, 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}
@@ -123,10 +123,11 @@ module Jobcompat
123
123
  private
124
124
 
125
125
  def resolve(raw_calls)
126
- raw_calls.sort_by { |call| [call.path, call.location.line, call.location.column] }.filter_map do |call|
126
+ raw_calls.sort_by { |call| [call.path, call.location.line, call.location.column, call.method,
127
+ call.arity.nil? ? Float::INFINITY : call.arity, call.unknown_reason.to_s] }.filter_map do |call|
127
128
  worker = nil
128
129
  if call.receiver
129
- candidates = if call.root
130
+ candidates = if call.resolution_mode == "exact_string" || call.root
130
131
  [call.receiver]
131
132
  elsif call.namespace.nil?
132
133
  []
@@ -134,9 +135,11 @@ module Jobcompat
134
135
  (call.namespace.reverse.map { |prefix| "#{prefix}::#{call.receiver}" } + [call.receiver]).uniq
135
136
  end
136
137
  worker = candidates.find { |name| @names.include?(name) }
137
- next unless worker || (!call.root && call.namespace.nil?)
138
+ client_target = %w[client_constant exact_string].include?(call.resolution_mode)
139
+ next unless worker || client_target || (!call.root && call.namespace.nil?)
138
140
  end
139
141
  reason = call.unknown_reason
142
+ reason ||= "dynamic_client_class" if call.receiver && worker.nil? && %w[client_constant exact_string].include?(call.resolution_mode)
140
143
  reason ||= call.namespace.nil? ? "unsupported_constant_path" : "dynamic_receiver" if worker.nil?
141
144
  {worker: worker, location: call.location, arity: call.arity, reason: reason, raw: call}
142
145
  end
@@ -1,3 +1,3 @@
1
1
  module Jobcompat
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.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.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - jobcompat contributors
@@ -42,10 +42,14 @@ files:
42
42
  - SECURITY.md
43
43
  - docs/architecture.md
44
44
  - docs/competitive-analysis.md
45
+ - docs/implementation-plan-v0.2.md
45
46
  - docs/implementation-plan.md
46
47
  - docs/release-checklist-v0.1.0.md
48
+ - docs/release-checklist-v0.2.0.md
47
49
  - docs/release-notes-v0.1.0.md
50
+ - docs/release-notes-v0.2.0.md
48
51
  - docs/spec-v0.1.md
52
+ - docs/spec-v0.2.md
49
53
  - exe/jobcompat
50
54
  - lib/jobcompat.rb
51
55
  - lib/jobcompat/analysis.rb