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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +31 -4
- data/docs/architecture.md +45 -5
- data/docs/implementation-plan-v0.3.md +32 -0
- data/docs/release-checklist-v0.3.0.md +109 -0
- data/docs/release-notes-v0.3.0.md +115 -0
- data/docs/spec-v0.3.md +238 -0
- data/lib/jobcompat/aliases.rb +86 -0
- data/lib/jobcompat/analysis.rb +76 -22
- data/lib/jobcompat/cli.rb +1 -1
- data/lib/jobcompat/engine.rb +104 -36
- data/lib/jobcompat/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b1df789562ba30ff3474a112bd44e602d68b833b7d34c92b936f8e508e20484
|
|
4
|
+
data.tar.gz: ba09c1c7f2ce1f22d44cf690559e395584be8492c5abbbad85d492281b6849cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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:
|
|
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
|
-
|
|
143
|
+
### Worker rename and static compatibility aliases
|
|
144
144
|
|
|
145
|
-
|
|
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,
|
|
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.
|
|
1
|
+
# jobcompat v0.3.0 architecture
|
|
2
2
|
|
|
3
|
-
Status: implemented design for `0.
|
|
4
|
-
Date: 2026-
|
|
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-
|
|
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
|
|
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
|
data/lib/jobcompat/analysis.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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:
|
|
101
|
+
{schema_version: 3, tool: {name: "jobcompat", version: VERSION}, status: status,
|
|
102
102
|
comparison: {deployment_model: "rolling", base: {ref: @base, sha: @base_sha}, head: {ref: @head, sha: @head_sha}},
|
|
103
103
|
configuration: {path: @loaded_config}, findings: findings, suppressions: suppressions, workers: workers,
|
|
104
104
|
diagnostics: diagnostics, summary: summary}
|
data/lib/jobcompat/engine.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Jobcompat
|
|
|
4
4
|
REVISIONS = %w[base head].freeze
|
|
5
5
|
TITLES = {
|
|
6
6
|
"JC001" => "Old payload rejected by new worker", "JC002" => "New payload rejected by old worker",
|
|
7
|
-
"JC003" => "Current producer/consumer mismatch", "JC004" => "Worker
|
|
7
|
+
"JC003" => "Current producer/consumer mismatch", "JC004" => "Worker identity absent from HEAD source",
|
|
8
8
|
"JC005" => "New worker enqueued before old fleet can understand it", "JC006" => "Worker contract narrowed without sufficient producer evidence",
|
|
9
9
|
"JC007" => "Compatibility could not be proven"
|
|
10
10
|
}.freeze
|
|
@@ -13,13 +13,18 @@ module Jobcompat
|
|
|
13
13
|
|
|
14
14
|
def initialize(base, head)
|
|
15
15
|
@base, @head = base, head
|
|
16
|
-
@
|
|
17
|
-
@calls = {"base" => resolve(base
|
|
16
|
+
@lookup_names = (base.workers.keys + head.workers.keys + base.aliases.keys + head.aliases.keys).uniq
|
|
17
|
+
@calls = {"base" => resolve(base), "head" => resolve(head)}
|
|
18
|
+
referenced = calls.values.flatten.flat_map { |call| [call[:worker], call[:attributed_worker]] }.compact
|
|
19
|
+
shared_aliases = (base.aliases.keys & head.aliases.keys).select { |name| base.aliases[name].static_reference || head.aliases[name].static_reference }
|
|
20
|
+
@names = (base.workers.keys + head.workers.keys +
|
|
21
|
+
base.aliases.values.select(&:resolved?).map(&:name) +
|
|
22
|
+
shared_aliases + referenced).uniq.sort
|
|
18
23
|
@calls_by_worker = {"base" => calls["base"].group_by { |call| call[:worker] },
|
|
19
24
|
"head" => calls["head"].group_by { |call| call[:worker] }}
|
|
20
25
|
@presence_requests = {
|
|
21
|
-
"head" =>
|
|
22
|
-
"base" =>
|
|
26
|
+
"head" => @names.select { |name| base.consumer(name) && !head.consumer(name) },
|
|
27
|
+
"base" => @names.select { |name| head.consumer(name) && !base.consumer(name) && @calls_by_worker["head"].key?(name) }
|
|
23
28
|
}
|
|
24
29
|
end
|
|
25
30
|
|
|
@@ -30,8 +35,8 @@ module Jobcompat
|
|
|
30
35
|
def evaluate
|
|
31
36
|
findings = []
|
|
32
37
|
workers = @names.map do |name|
|
|
33
|
-
b = base.
|
|
34
|
-
h = head.
|
|
38
|
+
b = base.consumer(name)
|
|
39
|
+
h = head.consumer(name)
|
|
35
40
|
bc = @calls_by_worker["base"].fetch(name, [])
|
|
36
41
|
hc = @calls_by_worker["head"].fetch(name, [])
|
|
37
42
|
base_presence = presence(base, name, b, presence_requests["base"].include?(name))
|
|
@@ -39,21 +44,21 @@ module Jobcompat
|
|
|
39
44
|
if b && !h
|
|
40
45
|
if head_presence == "absent"
|
|
41
46
|
findings << finding("JC004", name, ["base", "head"], ["base_to_head"], nil, nil,
|
|
42
|
-
"The worker
|
|
47
|
+
"The serialized worker identity is absent from HEAD tracked Ruby source.",
|
|
43
48
|
"Queued, retried, or scheduled jobs may still reference this class name.",
|
|
44
|
-
["Keep a compatibility
|
|
45
|
-
b.declaration_locations + b
|
|
49
|
+
["Keep the worker class or a supported compatibility alias until retained jobs can no longer run."],
|
|
50
|
+
b.declaration_locations + contract_proof(b) + producer_proof(hc))
|
|
46
51
|
else
|
|
47
|
-
findings << presence_unknown(name, head_presence, b.declaration_locations + head.index.locations(name), "base_to_head", "head")
|
|
52
|
+
findings << presence_unknown(name, head_presence, b.declaration_locations + head.index.locations(name), "base_to_head", "head") unless head.aliases.key?(name)
|
|
48
53
|
end
|
|
49
54
|
elsif h && !b && !hc.empty?
|
|
50
55
|
if base_presence == "absent"
|
|
51
56
|
findings << finding("JC005", name, ["base", "head"], ["head_to_base"], nil, nil,
|
|
52
|
-
"HEAD can enqueue a worker
|
|
57
|
+
"HEAD can enqueue a serialized worker identity absent from base tracked Ruby source.",
|
|
53
58
|
"If an old Sidekiq process can consume this job during the rolling deployment, it cannot resolve the new worker class.",
|
|
54
|
-
["Deploy the worker class before activating its producer."], h.declaration_locations + hc
|
|
59
|
+
["Deploy the worker class or compatibility alias before activating its producer."], h.declaration_locations + producer_proof(hc))
|
|
55
60
|
else
|
|
56
|
-
findings << presence_unknown(name, base_presence, h.declaration_locations + base.index.locations(name), "head_to_base", "base")
|
|
61
|
+
findings << presence_unknown(name, base_presence, h.declaration_locations + base.index.locations(name), "head_to_base", "base") unless base.aliases.key?(name)
|
|
57
62
|
end
|
|
58
63
|
end
|
|
59
64
|
|
|
@@ -66,7 +71,7 @@ module Jobcompat
|
|
|
66
71
|
head_same = head_groups.fetch(arity, []).select { |call| !h.accepts?(call[:arity]) }
|
|
67
72
|
directions = ["base_to_head"]
|
|
68
73
|
directions << "head_to_head" unless head_same.empty?
|
|
69
|
-
locations = group
|
|
74
|
+
locations = producer_proof(group) + contract_proof(b) + contract_proof(h) + producer_proof(head_same)
|
|
70
75
|
findings << finding("JC001", name, head_same.empty? ? ["base", "head"] : ["base", "head"], directions, arity, nil,
|
|
71
76
|
"Base emits #{arity} #{argument_word(arity)}, but the HEAD worker accepts #{h.display_range}.",
|
|
72
77
|
"Jobs queued by the base revision may fail after deployment.",
|
|
@@ -82,14 +87,14 @@ module Jobcompat
|
|
|
82
87
|
findings << finding("JC003", name, ["head"], ["head_to_head"], arity, nil,
|
|
83
88
|
"HEAD emits #{arity} #{argument_word(arity)}, but its worker accepts #{h.display_range}.",
|
|
84
89
|
"Current HEAD jobs can fail when executed.", ["Align the enqueue payload with the HEAD perform signature."],
|
|
85
|
-
group
|
|
90
|
+
producer_proof(group) + contract_proof(h))
|
|
86
91
|
current_removed_witness ||= b&.known? && b.accepts?(arity)
|
|
87
92
|
elsif b&.known? && !b.accepts?(arity)
|
|
88
93
|
findings << finding("JC002", name, ["base", "head"], ["head_to_base"], arity, nil,
|
|
89
94
|
"HEAD emits #{arity} #{argument_word(arity)}, but the base worker accepts #{b.display_range}.",
|
|
90
95
|
"A new producer can enqueue work that an old worker cannot execute during a rolling deploy.",
|
|
91
96
|
["Deploy the optional worker argument first.", "Start enqueueing the new argument in a later release."],
|
|
92
|
-
group
|
|
97
|
+
producer_proof(group) + contract_proof(b) + contract_proof(h))
|
|
93
98
|
end
|
|
94
99
|
end
|
|
95
100
|
end
|
|
@@ -101,12 +106,13 @@ module Jobcompat
|
|
|
101
106
|
"The HEAD worker removed #{label} #{removed.join(', ')} from the base contract; no qualifying producer witness was found.",
|
|
102
107
|
"No repository producer callsite found does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists.",
|
|
103
108
|
["Keep the broader signature through the retention window, or document a proven drain."],
|
|
104
|
-
b
|
|
109
|
+
contract_proof(b) + contract_proof(h))
|
|
105
110
|
end
|
|
106
111
|
end
|
|
107
112
|
|
|
108
113
|
{
|
|
109
114
|
name: name, base_presence: base_presence, head_presence: head_presence,
|
|
115
|
+
base_alias: base.aliases[name]&.as_json, head_alias: head.aliases[name]&.as_json,
|
|
110
116
|
base_contract: b&.as_json, head_contract: h&.as_json,
|
|
111
117
|
producer_arities: {base: bc.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort,
|
|
112
118
|
head: hc.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort,
|
|
@@ -122,35 +128,72 @@ module Jobcompat
|
|
|
122
128
|
|
|
123
129
|
private
|
|
124
130
|
|
|
125
|
-
def resolve(
|
|
126
|
-
|
|
131
|
+
def resolve(snapshot)
|
|
132
|
+
snapshot.calls.sort_by { |call| [call.path, call.location.line, call.location.column, call.method,
|
|
127
133
|
call.arity.nil? ? Float::INFINITY : call.arity, call.unknown_reason.to_s] }.filter_map do |call|
|
|
128
134
|
worker = nil
|
|
135
|
+
attributed_worker = nil
|
|
136
|
+
binding = nil
|
|
137
|
+
binding_locations = []
|
|
138
|
+
identity_reason = nil
|
|
129
139
|
if call.receiver
|
|
130
140
|
candidates = if call.resolution_mode == "exact_string" || call.root
|
|
131
141
|
[call.receiver]
|
|
132
|
-
elsif call.namespace.nil?
|
|
133
|
-
[]
|
|
134
142
|
else
|
|
135
|
-
(call.
|
|
143
|
+
ConstantLookup.candidates(call.receiver, false, call.namespace)
|
|
136
144
|
end
|
|
137
|
-
worker = candidates
|
|
145
|
+
worker = producer_identity(snapshot, candidates, call.resolution_mode)
|
|
146
|
+
attributed_worker = worker
|
|
147
|
+
if worker && call.resolution_mode != "exact_string" && snapshot.aliases.key?(worker)
|
|
148
|
+
binding = snapshot.aliases[worker]
|
|
149
|
+
worker = binding.resolved? ? binding.target : nil if binding
|
|
150
|
+
binding_locations = snapshot.workers.fetch(binding.target).locations if binding.resolved?
|
|
151
|
+
elsif worker && alias_lookup?(candidates, call.resolution_mode) && snapshot.bindings.key?(worker) && !snapshot.workers.key?(worker)
|
|
152
|
+
binding_locations = snapshot.bindings.fetch(worker).map(&:location)
|
|
153
|
+
identity_reason = "worker_not_recognized"
|
|
154
|
+
worker = nil
|
|
155
|
+
elsif worker && call.resolution_mode != "exact_string" && !snapshot.workers.key?(worker) && (base.aliases.key?(worker) || head.aliases.key?(worker))
|
|
156
|
+
worker = nil
|
|
157
|
+
end
|
|
138
158
|
client_target = %w[client_constant exact_string].include?(call.resolution_mode)
|
|
139
|
-
next unless worker || client_target || (!call.root && call.namespace.nil?)
|
|
159
|
+
next unless worker || attributed_worker || client_target || (!call.root && call.namespace.nil?)
|
|
140
160
|
end
|
|
141
|
-
|
|
161
|
+
identity_reason ||= binding && !binding.resolved? ? binding.unknown_reason : nil
|
|
162
|
+
identity_reason ||= "alias_target_unresolved" if worker.nil? && attributed_worker
|
|
163
|
+
reason = identity_reason || call.unknown_reason
|
|
142
164
|
reason ||= "dynamic_client_class" if call.receiver && worker.nil? && %w[client_constant exact_string].include?(call.resolution_mode)
|
|
143
165
|
reason ||= call.namespace.nil? ? "unsupported_constant_path" : "dynamic_receiver" if worker.nil?
|
|
144
|
-
{worker: worker,
|
|
166
|
+
{worker: worker, attributed_worker: attributed_worker, identity_known: !worker.nil?, identity_reason: identity_reason,
|
|
167
|
+
location: call.location, locations: [call.location] + (binding&.locations || []) + binding_locations,
|
|
168
|
+
arity: call.arity, reason: reason, raw: call}
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def producer_identity(snapshot, candidates, mode)
|
|
173
|
+
if alias_lookup?(candidates, mode)
|
|
174
|
+
local = candidates.find { |name| snapshot.bindings.key?(name) || snapshot.workers.key?(name) || snapshot.aliases.key?(name) }
|
|
175
|
+
return local if local
|
|
145
176
|
end
|
|
177
|
+
candidates.find { |name| @lookup_names.include?(name) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def alias_lookup?(candidates, mode)
|
|
181
|
+
mode != "exact_string" && candidates.any? { |name| base.aliases.key?(name) || head.aliases.key?(name) }
|
|
146
182
|
end
|
|
147
183
|
|
|
148
184
|
def presence(snapshot, name, contract, queried)
|
|
149
|
-
return
|
|
185
|
+
return snapshot.presence(name) if contract || snapshot.aliases.key?(name)
|
|
150
186
|
return "not_checked" unless queried
|
|
151
187
|
snapshot.index.status(name)
|
|
152
188
|
end
|
|
153
189
|
|
|
190
|
+
def producer_proof(group) = group.flat_map { |call| call[:locations] }
|
|
191
|
+
|
|
192
|
+
def contract_proof(contract)
|
|
193
|
+
return contract.locations if base.aliases.key?(contract.name) || head.aliases.key?(contract.name)
|
|
194
|
+
contract.locations.select { |location| location.role == "consumer" }
|
|
195
|
+
end
|
|
196
|
+
|
|
154
197
|
def cell(producers, consumer, status)
|
|
155
198
|
return "not_applicable" if producers.empty?
|
|
156
199
|
known = producers.filter_map { |call| call[:arity] unless call[:reason] }
|
|
@@ -196,22 +239,32 @@ module Jobcompat
|
|
|
196
239
|
def unknown_findings
|
|
197
240
|
roots = []
|
|
198
241
|
[base, head].each do |snapshot|
|
|
199
|
-
|
|
200
|
-
|
|
242
|
+
@names.each do |name|
|
|
243
|
+
contract = snapshot.consumer(name)
|
|
244
|
+
next if !contract || contract.known?
|
|
201
245
|
parts = ["consumer_contract", contract.unknown_reason, name, contract.fingerprint_parts]
|
|
202
246
|
roots << {revision: snapshot.label, kind: "consumer_contract", reason: contract.unknown_reason, worker: name,
|
|
203
247
|
locations: contract.locations, parts: parts, offset: contract.locations.first&.line || 0}
|
|
204
248
|
end
|
|
249
|
+
snapshot.aliases.each do |name, binding|
|
|
250
|
+
next if binding.resolved? || !@names.include?(name)
|
|
251
|
+
producer_locations = calls[snapshot.label].select { |call| call[:identity_reason] && call[:attributed_worker] == name }.flat_map { |call| call[:locations] }
|
|
252
|
+
roots << {revision: snapshot.label, kind: "consumer_alias", reason: binding.unknown_reason, worker: name,
|
|
253
|
+
locations: binding.locations + producer_locations,
|
|
254
|
+
parts: ["consumer_alias", binding.unknown_reason, name, binding.fingerprint_parts, binding.chain], offset: 0}
|
|
255
|
+
end
|
|
205
256
|
snapshot.unknowns.each do |item|
|
|
206
257
|
roots << {revision: snapshot.label, kind: item.kind, reason: item.reason, worker: item.worker,
|
|
207
258
|
locations: item.locations, parts: [item.kind, item.reason, item.worker, item.fingerprint_parts], offset: item.locations.first&.line || 0}
|
|
208
259
|
end
|
|
209
260
|
calls[snapshot.label].each do |call|
|
|
210
261
|
next unless call[:reason]
|
|
262
|
+
next if call[:identity_reason] && snapshot.aliases.key?(call[:attributed_worker])
|
|
211
263
|
raw = call[:raw]
|
|
212
|
-
kind = %w[dynamic_receiver unsupported_constant_path safe_navigation_receiver].include?(call[:reason]) ? "producer_receiver" : "producer_arity"
|
|
213
|
-
|
|
214
|
-
|
|
264
|
+
kind = call[:identity_reason] || %w[dynamic_receiver unsupported_constant_path safe_navigation_receiver].include?(call[:reason]) ? "producer_receiver" : "producer_arity"
|
|
265
|
+
diagnostic_worker = call[:worker] || (call[:identity_reason] && call[:attributed_worker])
|
|
266
|
+
roots << {revision: snapshot.label, kind: kind, reason: call[:reason], worker: diagnostic_worker,
|
|
267
|
+
locations: call[:locations], parts: [kind, call[:reason], diagnostic_worker, raw.fingerprint_parts],
|
|
215
268
|
offset: [call[:location].line, call[:location].column]}
|
|
216
269
|
end
|
|
217
270
|
end
|
|
@@ -227,20 +280,35 @@ module Jobcompat
|
|
|
227
280
|
when ["producer_arity", "base"], ["producer_receiver", "base"] then ["base_to_head"]
|
|
228
281
|
when ["producer_arity", "head"], ["producer_receiver", "head"] then ["head_to_base", "head_to_head"]
|
|
229
282
|
when ["consumer_contract", "base"], ["worker_identity", "base"] then ["head_to_base"]
|
|
283
|
+
when ["consumer_alias", "base"] then ["head_to_base"]
|
|
284
|
+
when ["consumer_alias", "head"]
|
|
285
|
+
@calls_by_worker["head"].key?(root[:worker]) ? ["base_to_head", "head_to_head"] : ["base_to_head"]
|
|
230
286
|
else ["base_to_head", "head_to_head"]
|
|
231
287
|
end
|
|
232
288
|
end.uniq
|
|
233
289
|
if first[:kind] == "producer_arity" && first[:worker]
|
|
234
|
-
head_only_new = head.
|
|
290
|
+
head_only_new = head.consumer(first[:worker]) && !base.consumer(first[:worker])
|
|
235
291
|
directions.delete("head_to_base") if head_only_new && group.any? { |root| root[:revision] == "head" } && base.index.status(first[:worker]) == "absent"
|
|
236
292
|
end
|
|
293
|
+
alias_reason = %w[alias_target_unresolved alias_cycle alias_binding_conflict unsupported_alias_assignment].include?(first[:reason])
|
|
294
|
+
message = alias_reason ? alias_message(first[:worker], first[:reason]) : "#{first[:reason]} prevents a complete positional compatibility check."
|
|
295
|
+
risk = alias_reason ? "Compatibility for jobs serialized as #{first[:worker]} cannot be proven from selected source." : "The affected producer or consumer contract is unknown."
|
|
296
|
+
remediation = alias_reason ? ["Use one explicit static alias if it matches runtime behavior, or inspect the deployment manually."] : ["Use supported explicit syntax or inspect this call manually."]
|
|
237
297
|
finding("JC007", first[:worker], group.map { |root| root[:revision] }.uniq, directions, nil, first[:reason],
|
|
238
|
-
|
|
239
|
-
"The affected producer or consumer contract is unknown.", ["Use supported explicit syntax or inspect this call manually."],
|
|
298
|
+
message, risk, remediation,
|
|
240
299
|
group.flat_map { |root| root[:locations] })
|
|
241
300
|
end
|
|
242
301
|
end
|
|
243
302
|
|
|
303
|
+
def alias_message(name, reason)
|
|
304
|
+
case reason
|
|
305
|
+
when "alias_target_unresolved" then "#{name} is bound as a compatibility alias, but its target cannot be resolved to a supported Sidekiq worker."
|
|
306
|
+
when "alias_cycle" then "Compatibility alias resolution for #{name} enters a constant-alias cycle."
|
|
307
|
+
when "alias_binding_conflict" then "#{name} has multiple or conflicting constant bindings, so its runtime job class cannot be proven."
|
|
308
|
+
when "unsupported_alias_assignment" then "#{name} is assigned through syntax or control flow that does not prove one unconditional static compatibility alias."
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
244
312
|
def finding_sort_key(item)
|
|
245
313
|
loc = item[:locations].first
|
|
246
314
|
[item[:severity] == "error" ? 0 : 1, item[:rule_id], item[:worker] || "\u{10ffff}", item[:payload_arity] || Float::INFINITY,
|
data/lib/jobcompat/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jobcompat
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- jobcompat contributors
|
|
@@ -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
|