jobcompat 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ # jobcompat v0.2.1 normative delta specification
2
+
3
+ Status: normative for `0.2.1`
4
+
5
+ This document is a small delta from [`spec-v0.2.md`](spec-v0.2.md), which remains the normative `0.2.0` delta from [`spec-v0.1.md`](spec-v0.1.md). **All v0.1 and v0.2 rules remain normative unless explicitly overridden here.** v0.2.1 is a maintenance release: it adds no producer syntax, rule, finding field, uncertainty reason, or configuration key.
6
+
7
+ ## 1. Versioned contracts
8
+
9
+ - Gem and CLI version: `0.2.1`.
10
+ - JSON `schema_version`: `2` (unchanged).
11
+ - `.jobcompat.yml` version: `1` (unchanged; `version: 2` remains a config error).
12
+
13
+ No user migration is required.
14
+
15
+ ## 2. Canonical worker-name validation
16
+
17
+ A canonical String worker name is a String that:
18
+
19
+ 1. converts safely to valid UTF-8;
20
+ 2. parses with the supported Prism (`>= 1.9, < 2`) without diagnostics;
21
+ 3. contains exactly one expression;
22
+ 4. is a static `ConstantReadNode` or `ConstantPathNode` chain, not root-qualified with a leading `::`;
23
+ 5. when flattened and joined with `::`, equals the complete String exactly.
24
+
25
+ Prism is the only grammar authority; jobcompat defines no handwritten identifier, case, combining-mark, or Unicode-range rules. Input is never trimmed. Whitespace, comments, newlines, parentheses, calls, operators, extra statements, `self::`, and any other syntax make the String non-canonical.
26
+
27
+ One internal validator applies this definition to both Client class Strings (`Sidekiq::Client.push` / `push_bulk`) and `.jobcompat.yml` `ignore[].worker`.
28
+
29
+ **Invariant:** any worker name accepted as an exact canonical String worker name by producer analysis MUST also be valid as `.jobcompat.yml` `ignore[].worker`, subject to the same canonical textual identity rules. Conversely, a configured worker name that producer analysis could not accept as a canonical String is invalid configuration.
30
+
31
+ Constant AST resolution (for example `"class" => ExportJob`, `ExportJob.perform_async`, and worker declarations) is unchanged and is not governed by this section.
32
+
33
+ ## 3. Suppression semantics
34
+
35
+ Matching remains exact on `rule_id` plus the worker String. There is no Unicode normalization (no NFC, NFD, NFKC, or NFKD), case folding, namespace inference, partial, glob, or regex matching. Configured names are stored and reported exactly as written.
36
+
37
+ Visually similar but codepoint-distinct valid names remain different worker identities. For example, `ÁJob` written as `U+00C1 J o b` and as `U+0041 U+0301 J o b` are distinct: a suppression for one does not suppress a finding for the other, and the two entries are not duplicates of each other. Two entries with the same rule and exactly the same worker String remain a duplicate-entry config error.
38
+
39
+ A qualified name such as `Admin::ÁJob` suppresses only the finding whose worker is exactly that qualified name; `ÁJob` alone does not match it.
40
+
41
+ A matched suppression keeps the existing audit record (`rule_id`, exact `worker`, `reason`, `finding_count`) in JSON and text output, and a suppressed compatibility ERROR no longer contributes to exit 1.
42
+
43
+ ## 4. Invalid worker-name behavior
44
+
45
+ The shared validator only decides validity. Each caller owns its failure surface:
46
+
47
+ - **Config:** a non-canonical, non-String, or invalid-encoding `ignore[].worker` is a `config_error` with the existing message `ignore[N].worker is invalid`, JSON `status: "failed"`, and exit 2. A document whose bytes Psych rejects before validation is also a `config_error` with exit 2. No raw `ArgumentError` or `EncodingError`, `internal_error`, or stack trace is produced.
48
+ - **Producer analysis:** a non-canonical or invalid-encoding Client class String remains `unsupported_client_payload` JC007 evidence, as in v0.2.0.
49
+
50
+ Inside the validator only expected encoding failures are treated as unsupported input; unexpected parser or implementation failures stay visible as tool failures.
51
+
52
+ Compatibility note: v0.2.0 also accepted some configured names that Ruby does not parse as constants, such as `管理::輸出` (segments that do not start with an uppercase letter). No finding could ever carry such a worker name, so those entries never suppressed anything; v0.2.1 reports them as `config_error`.
53
+
54
+ ## 5. Compatibility invariants
55
+
56
+ - JC001–JC007 semantics, severities, and precedence are unchanged; there is no JC008.
57
+ - Exit codes are unchanged: 0 no unsuppressed compatibility ERROR, 1 compatibility ERROR, 2 tool/config/Git/parser/usage failure.
58
+ - JSON schema 2 structure and `unknown_reason` vocabulary are unchanged.
59
+ - Runtime dependencies remain `prism >= 1.9, < 2`; Ruby `>= 3.3`.
60
+ - Validation parses text only: no eval, `const_get`, constantize, application `require`, Rails or Sidekiq boot, Redis, source execution, or analysis-time network.
61
+ - Output remains byte-deterministic, including exact Unicode codepoints.
62
+
63
+ ## 6. Non-goals
64
+
65
+ New Sidekiq producer APIs, SARIF, ActiveJob, keyword compatibility, payload type or Hash schema checking, Redis inspection, wrapper tracking, aliases, inheritance analysis, general dataflow, feature-flag analysis, new suppression syntax, wildcard suppressions, and regex suppressions.
data/docs/spec-v0.3.md ADDED
@@ -0,0 +1,238 @@
1
+ # jobcompat v0.3.0 delta specification
2
+
3
+ Status: normative for 0.3.0. This document extends `spec-v0.2.1.md`,
4
+ which retains the v0.2 and v0.1 contracts except where overridden here.
5
+ MUST and MUST NOT state requirements. No application Ruby is executed.
6
+
7
+ ## Versioned and frozen contracts
8
+
9
+ Gem and CLI version: 0.3.0. Completed and failed JSON envelopes MUST use
10
+ `schema_version: 3`. Configuration version remains exactly 1; version 2 is
11
+ invalid. Ruby >= 3.3 and Prism >= 1.9, < 2 remain unchanged. No dependency
12
+ on Sidekiq is introduced. JC001-JC007 IDs, severities (JC001-JC005 ERROR,
13
+ JC006-JC007 WARNING), exit policy, suppression identity, and deployment
14
+ directions remain unchanged. ERROR requires provable structural
15
+ incompatibility. Unknown and unsupported evidence never establishes
16
+ compatibility; missing producers never establishes an empty queue.
17
+ Analysis reads committed Git objects and ignores working-tree edits.
18
+
19
+ ## Terminology and serialization
20
+
21
+ **Serialized worker identity** is the exact String in a Sidekiq payload's
22
+ `"class"` field. All compatibility comparisons use that identity.
23
+ **Direct worker** is a canonical class recognized through the existing
24
+ direct `Sidekiq::Job` or legacy `Sidekiq::Worker` include and reopened-class
25
+ discovery. **Compatibility alias** is a statically provable constant
26
+ binding to another constant whose graph terminates in one recognized
27
+ direct worker in the same revision. **Alias terminal** is that final
28
+ direct worker after zero or more supported edges.
29
+ **Class-object producer** passes a Ruby Class object (worker APIs,
30
+ Setter APIs, or Client constant payloads). **Exact-string producer** passes
31
+ an already serialized identity through Client push or push_bulk.
32
+
33
+ For `OldJob = NewJob`, Ruby retains the Class object's canonical name
34
+ `NewJob`. Class-object producers through OldJob MUST therefore normalize
35
+ to NewJob. This applies to perform_async, perform_in, perform_at,
36
+ perform_bulk, all four `.set(...)` forms, and Client push/push_bulk with a
37
+ constant. A Client `"class" => "OldJob"` MUST retain exactly OldJob on the
38
+ producer side; consumer lookup MAY resolve OldJob through its alias.
39
+ There is no namespace prefixing or Unicode normalization of exact Strings.
40
+
41
+ The model freezes Sidekiq's Class-to-String normalization as observed in
42
+ [Sidekiq 8.1.6 JobUtil](https://github.com/sidekiq/sidekiq/blob/v8.1.6/lib/sidekiq/job_util.rb#L38-L55)
43
+ and its [Class-object producer path](https://github.com/sidekiq/sidekiq/blob/v8.1.6/lib/sidekiq/job.rb#L191-L196).
44
+ The [official rename FAQ](https://github.com/sidekiq/sidekiq/wiki/FAQ#how-do-i-safely-rename-a-worker)
45
+ describes a compatibility alias for retained old payloads. Client Strings
46
+ have different job-option/default semantics; their identity behavior is
47
+ documented here without recommending them as a migration strategy.
48
+
49
+ ## Supported syntax and namespace semantics
50
+
51
+ Only Prism ConstantWriteNode and ConstantPathWriteNode with plain `=`
52
+ and a ConstantReadNode or static ConstantPathNode RHS after safe
53
+ single-expression parentheses unwrapping prove an alias. The assignment
54
+ MUST be a direct unconditional statement in the Program body or a valid
55
+ Class/Module body, with all enclosing contexts supported.
56
+
57
+ Supported examples are `OldJob = NewJob`, `OldJob = (NewJob)`,
58
+ `::OldJob = ::NewJob`, top-level `Admin::OldJob = Admin::NewJob`, and
59
+ `module Admin; OldJob = NewJob; end`. Existing declaration rules determine
60
+ the LHS canonical name. An unrooted qualified LHS in lexical nesting or
61
+ an unsupported namespace MUST NOT prove an alias. An unrooted qualified
62
+ RHS inside lexical nesting is unsupported_alias_assignment. Rooted RHS
63
+ references and qualified top-level RHS references are exact.
64
+
65
+ Unqualified RHS lookup uses the existing lexical namespace stack in
66
+ reverse order, then the root name. For nested Admin/Jobs, NewJob candidates
67
+ are Admin::Jobs::NewJob, Admin::NewJob, NewJob. The first selected-source
68
+ binding wins lookup; a binding with an unknown value MUST block fallback
69
+ to a farther known worker. No inherited-constant lookup is added.
70
+
71
+ Assignments below if/unless/case/loops/blocks/lambdas/begin/rescue/ensure,
72
+ modifier conditions, singleton classes, methods, or expression wrappers
73
+ are unsupported. Even an unconditional begin wrapper is unsupported.
74
+ Or/and/operator writes, multiple assignment targets, dynamic RHS,
75
+ ternary RHS, calls, and Class.new MUST NOT become resolved aliases.
76
+ Named unsupported writes enter the inventory so relevant identities can
77
+ warn and conflicts cannot be hidden. const_set/autoload/const_missing
78
+ remain outside alias discovery.
79
+
80
+ ## Selected binding inventory and graph
81
+
82
+ During the existing Prism traversal, record selected-source class/module
83
+ declarations and named writes, plus alias candidates. Presence facts in
84
+ DefinedConstantIndex MUST remain separate from alias graph facts.
85
+ Exactly one supported alias binding and no other binding to its LHS is
86
+ required. Duplicate aliases, even identical ones, alias plus class/module,
87
+ or alias plus unsupported writes produce alias_binding_conflict. No
88
+ source-order winner is selected. Existing reopened direct class fragments
89
+ are allowed and merged as before; they are not conflicts by themselves.
90
+ An alias that reaches a conflicting binding MUST remain unknown.
91
+ Selected ambiguous constant paths also retain leaf-name conflict evidence:
92
+ an assignment or declaration such as `self::OldJob = OtherJob` cannot be
93
+ ignored when its leaf may affect an alias or a reached terminal. These
94
+ facts conservatively block resolution without evaluating the receiver.
95
+ Excluded or differently named ambiguous facts do not enter this proof.
96
+
97
+ Each revision resolves its own graph independently. Finite chains have
98
+ no arbitrary length cap. Iterative traversal detects cycles without
99
+ recursive stack overflow. Resolved chain order follows edges, for example
100
+ `[OldJob, MiddleJob, NewJob]`. A cycle chain closes with its repeated
101
+ identity, for example `[OldJob, MiddleJob, OldJob]`. A missing or
102
+ unrecognized terminal is alias_target_unresolved. Failed suffixes
103
+ propagate their reason and proof to aliases depending on them.
104
+
105
+ Only selected files supply trusted edges. Excluded declarations/writes
106
+ may block absence through the presence pass, but MUST NOT resolve aliases.
107
+ An excluded OldJob alias yields outside_analysis_scope for a removal
108
+ transition; a selected alias targeting an excluded worker yields
109
+ alias_target_unresolved. File movement alone does not alter identity
110
+ compatibility. All keys preserve Prism's exact UTF-8 codepoints.
111
+
112
+ ## Effective consumers and relevant identities
113
+
114
+ Snapshot workers remain directly recognized workers. An effective
115
+ consumer map resolves serialized identity S to either direct worker S or
116
+ the terminal contract of supported alias S. A conflicting alias masks an
117
+ otherwise recognized direct consumer at the same identity. Resolved
118
+ aliases retain resolution status even when the terminal signature is
119
+ unknown (keyword_parameters, missing_perform, multiple_perform_definitions).
120
+ Effective alias contracts carry every edge location and the terminal's
121
+ declaration/include/perform proof. No new location role is introduced.
122
+
123
+ Comparison identities MUST include all direct workers in either revision,
124
+ all resolved BASE aliases (historical queues may retain their names),
125
+ aliases present in both revisions, aliases matching an opposite direct
126
+ worker, and aliases referenced by visible producers or needed to explain
127
+ unknown producer identity. A dormant HEAD-only alias is not automatically
128
+ a worker result or standalone warning. The full graph may still be built.
129
+ The common-alias criterion refers to constant-to-constant candidates,
130
+ including unsupported static forms; unrelated dynamic/literal writes such
131
+ as VERSION = "1" do not become relevant merely by existing in both
132
+ revisions. They still block conflicting bindings and are diagnosed when
133
+ referenced by a producer or matched to an opposite worker identity.
134
+
135
+ ## Producer resolution
136
+
137
+ Class-object resolution first performs existing lexical/root lookup in
138
+ the producer's own snapshot, including alias bindings. A resolved alias
139
+ normalizes to its terminal direct identity. An unknown alias MUST NOT
140
+ guess its syntactic name as a serialized identity. Such a call retains a
141
+ separate attributed alias name for diagnostics and comparison relevance,
142
+ but cannot drive JC005. Existing cross-revision non-alias attribution
143
+ remains available only when no local alias/binding narrows that proof.
144
+ When alias lookup applies, a nearer selected non-worker class/module
145
+ binding stops fallback and leaves producer identity unknown. Opposite
146
+ revision names cannot override a selected local binding. Normalized
147
+ Class-object alias producers retain edge and terminal declaration,
148
+ include, and perform locations; unresolved targets retain any selected
149
+ declaration that prevents recognition.
150
+
151
+ Exact-string resolution remains exact and does not canonicalize aliases.
152
+ Producer identity certainty and arity certainty MUST be distinct:
153
+ resolved alias plus `*args` retains the terminal identity and splat_arguments;
154
+ unknown alias plus literal args retains unknown identity. Known identity
155
+ with unknown arity can still drive JC005. Existing Hash source-order,
156
+ dynamic-key, bulk-row, lexical producer, and Client String rules persist.
157
+
158
+ ## Rules, unknown reasons, and proof
159
+
160
+ JC001: old serialized payload accepted by BASE but rejected by HEAD,
161
+ including alias consumers. JC002: HEAD accepted payload rejected by BASE,
162
+ under its serialized identity. JC003: HEAD producer/consumer mismatch.
163
+ JC001 owns a repeated HEAD mismatch at the same identity/arity before
164
+ JC003. JC001 OldJob and JC003 NewJob from a Class alias producer remain
165
+ distinct findings. JC006 narrowing/witness precedence remains unchanged.
166
+
167
+ JC004 requires absence of a BASE effective serialized identity in HEAD.
168
+ A resolved alias blocks removal; a relevant unknown alias warns rather
169
+ than passing or proving absence. Removing a BASE resolved alias can cause
170
+ JC004 even if its terminal still exists. JC005 requires HEAD known
171
+ serialized producer identity with an effective HEAD consumer and proven
172
+ BASE absence, conditional on old processes consuming that queue. An
173
+ exact-string alias producer can cause JC005 for the alias; a Class-object
174
+ alias producer can cause JC005 only for the terminal. BASE resolved aliases
175
+ block absence; unresolved aliases warn. Existing presence budget/error
176
+ behavior and conservative absence proof remain frozen.
177
+
178
+ JC007 adds exactly four reasons: alias_target_unresolved (supported edge
179
+ does not reach a recognized worker), alias_cycle, alias_binding_conflict,
180
+ and unsupported_alias_assignment. Existing contract/arity reasons retain
181
+ their meanings. Alias warning messages describe the unresolved binding
182
+ and risk to the persisted identity; remediation suggests an explicit
183
+ static alias only if it matches runtime behavior, or manual inspection.
184
+
185
+ BASE alias uncertainty affects head_to_base. HEAD alias uncertainty affects
186
+ base_to_head and additionally head_to_head only with a HEAD producer for
187
+ that same serialized identity. A Class call normalizing to another name
188
+ does not manufacture head_to_head for its source alias. Alias evidence
189
+ fingerprints contain reason, exact identity, lexical scope, normalized
190
+ assignment/enclosing tokens, target reference, and structural chain, but
191
+ exclude revision, paths, lines, and columns. Stable binding/occurrence
192
+ ordering preserves distinct evidence and cross-revision aggregation.
193
+
194
+ ## JSON schema 3 and determinism
195
+
196
+ workers[] now represents compared serialized identities, sorted by exact
197
+ name. Every result has base_alias and head_alias, null for a direct or
198
+ absent binding, otherwise an object with exactly status, target, chain,
199
+ unknown_reason. Resolved status has terminal target and null reason;
200
+ unknown status has null target and its alias reason. Conflict or unsupported
201
+ assignment chains start with the alias alone; unresolved target chains
202
+ include the missing target, and cycle chains close the cycle.
203
+
204
+ Presence values are recognized_worker, resolved_alias,
205
+ defined_unrecognized, outside_scan_scope, absent, unverified, not_checked.
206
+ Unknown aliases use defined_unrecognized even in a direct binding conflict.
207
+ Effective contracts appear in base_contract/head_contract for resolved
208
+ aliases; unresolved aliases have null contracts. Producer arities and
209
+ matrix statuses use known serialized identity and effective consumers.
210
+ summary.workers continues to count direct worker facts only, never aliases.
211
+ Suppressions match the finding's exact serialized worker identity.
212
+
213
+ Inputs and graph processing use stable sorting. Chains follow structural
214
+ edge order. Repeated text/JSON for identical refs/config/toolchain MUST be
215
+ byte-identical, including cycles, conflicts, and Unicode names.
216
+
217
+ ## Boundaries and acceptance
218
+
219
+ Recognition assumes normal successful application boot, as direct worker
220
+ recognition already does; it does not prove file load order or Zeitwerk
221
+ behavior. No general constant evaluator/dataflow, inheritance/concerns,
222
+ source execution, Rails/Sidekiq boot, Redis/network access, keyword
223
+ compatibility, value/Hash schema analysis, ActiveJob, Client instances,
224
+ wrappers, queue/feature-flag simulation, manual alias config, heuristic
225
+ rename detection, JC008, SARIF, --explain, or coverage report is added.
226
+
227
+ Acceptance requires supported AST/context and namespace tests, finite and
228
+ cross-file graph tests, cycles/conflicts/unknown terminals, reopened and
229
+ unknown-contract targets, all Class-object and exact-string producer
230
+ families, separate identity/arity uncertainty, every JC rule and precedence,
231
+ staged rename and alias removal, excluded-source presence proof, exact
232
+ Unicode/suppression behavior, schema 3 (success and failure), unchanged
233
+ config version 1, deterministic output, existing regressions, source
234
+ non-execution/worktree/index immutability, offline tests, gem packaging,
235
+ isolated CLI install/scenarios, and full validation in UTF-8 locale.
236
+ Locally unavailable Ruby 3.3/4.0 matrix entries remain official CI work.
237
+ The implementation phase MUST leave an uncommitted candidate and MUST NOT
238
+ commit, push, tag, release, publish, or alter historical v0.2.1 artifacts.
@@ -0,0 +1,86 @@
1
+ module Jobcompat
2
+ ConstantBinding = Data.define(:name, :kind, :location, :fingerprint_parts)
3
+ AliasCandidate = Data.define(:name, :reference, :root, :namespace, :supported, :location, :fingerprint_parts)
4
+ AliasResolution = Data.define(:name, :status, :target, :chain, :unknown_reason, :locations, :fingerprint_parts, :static_reference) do
5
+ def resolved? = status == "resolved"
6
+ def as_json
7
+ {status: status, target: target, chain: chain, unknown_reason: unknown_reason}
8
+ end
9
+ end
10
+
11
+ module ConstantLookup
12
+ def self.candidates(reference, root, namespace)
13
+ return [reference] if root
14
+ return [] if namespace.nil?
15
+ (namespace.reverse.map { |prefix| "#{prefix}::#{reference}" } + [reference]).uniq
16
+ end
17
+ end
18
+
19
+ class AliasResolver
20
+ def initialize(workers, bindings, candidates, ambiguous_bindings = [])
21
+ @workers = workers
22
+ @bindings = bindings.group_by(&:name)
23
+ @candidates = candidates.group_by(&:name)
24
+ @ambiguous_bindings = ambiguous_bindings.group_by(&:name)
25
+ end
26
+
27
+ def resolve
28
+ @candidates.keys.sort.to_h { |name| [name, resolve_identity(name)] }.freeze
29
+ end
30
+
31
+ private
32
+
33
+ def resolve_identity(name)
34
+ chain, locations, parts, visited = [], [], [], {}
35
+ current = name
36
+ reason = nil
37
+ loop do
38
+ chain << current
39
+ if visited[current]
40
+ reason = "alias_cycle"
41
+ break
42
+ end
43
+ visited[current] = true
44
+ candidates = @candidates.fetch(current, [])
45
+ bindings = @bindings.fetch(current, []).sort_by { |item| [item.location.path, item.location.line, item.location.column] }
46
+ ambiguous = @ambiguous_bindings.fetch(current.split("::").last, []).reject { |item| bindings.any? { |binding| binding.location == item.location } }
47
+ if candidates.empty? && @workers.key?(current) && ambiguous.empty?
48
+ return resolution(name, current, chain, nil, locations, parts)
49
+ end
50
+ locations.concat((bindings + ambiguous).map(&:location))
51
+ parts << [current, (bindings + ambiguous).map(&:fingerprint_parts).sort_by(&:inspect)]
52
+ unless ambiguous.empty?
53
+ reason = "alias_binding_conflict"
54
+ break
55
+ end
56
+ if candidates.empty?
57
+ reason = "alias_target_unresolved"
58
+ break
59
+ end
60
+ if bindings.length != 1
61
+ reason = "alias_binding_conflict"
62
+ break
63
+ end
64
+ candidate = candidates.first
65
+ unless candidate.supported
66
+ reason = "unsupported_alias_assignment"
67
+ break
68
+ end
69
+ lookup = ConstantLookup.candidates(candidate.reference, candidate.root, candidate.namespace)
70
+ target = lookup.find { |identity| @bindings.key?(identity) || @workers.key?(identity) }
71
+ unless target
72
+ chain << lookup.first
73
+ reason = "alias_target_unresolved"
74
+ break
75
+ end
76
+ current = target
77
+ end
78
+ resolution(name, nil, chain, reason, locations, parts)
79
+ end
80
+
81
+ def resolution(name, target, chain, reason, locations, parts)
82
+ AliasResolution.new(name, reason ? "unknown" : "resolved", target, chain.freeze, reason,
83
+ locations.uniq.freeze, parts.freeze, @candidates.fetch(name).any? { |candidate| candidate.reference })
84
+ end
85
+ end
86
+ end
@@ -1,4 +1,6 @@
1
1
  require "prism"
2
+ require_relative "canonical_worker_name"
3
+ require_relative "aliases"
2
4
 
3
5
  module Jobcompat
4
6
  Location = Data.define(:revision, :path, :line, :column, :role, :excerpt) do
@@ -55,14 +57,32 @@ module Jobcompat
55
57
  end
56
58
 
57
59
  class RevisionSnapshot
58
- 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
59
61
 
60
- 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: [])
61
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))
62
82
  end
63
83
 
64
84
  def check_presence(names, repository, config)
65
- unresolved = names.reject { |name| index.status(name, recognized: workers.key?(name)) != "absent" }
85
+ unresolved = names.select { |name| presence(name) == "absent" }
66
86
  return if unresolved.empty?
67
87
  consumed = 0
68
88
  eligible = []
@@ -104,11 +124,12 @@ module Jobcompat
104
124
  PATH_CONSTANT_BINDINGS = [Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode,
105
125
  Prism::ConstantPathAndWriteNode, Prism::ConstantPathOperatorWriteNode,
106
126
  Prism::ConstantPathTargetNode].freeze
107
- attr_reader :fragments, :calls, :unknowns
127
+ attr_reader :fragments, :calls, :unknowns, :bindings, :alias_candidates, :ambiguous_bindings
108
128
 
109
129
  def initialize(revision, path, source, index:, presence_only: false)
110
130
  @revision, @path, @source, @index, @presence_only = revision, path, source, index, presence_only
111
131
  @fragments, @calls, @unknowns = [], [], []
132
+ @bindings, @alias_candidates, @ambiguous_bindings = [], [], []
112
133
  end
113
134
 
114
135
  def analyze
@@ -121,7 +142,7 @@ module Jobcompat
121
142
  end
122
143
  raise Error.new(diagnostics.first[:message], category: "parse_error", location: diagnostics.first[:location], diagnostics: diagnostics)
123
144
  end
124
- walk(result.value, [], [], nil)
145
+ walk(result.value, [], [], nil, true)
125
146
  self
126
147
  end
127
148
 
@@ -155,19 +176,30 @@ module Jobcompat
155
176
 
156
177
  private
157
178
 
158
- def walk(node, namespace, scope, statement)
179
+ def walk(node, namespace, scope, statement, alias_context = false, alias_statement = nil)
159
180
  return unless node.is_a?(Prism::Node)
160
181
  case node
182
+ when Prism::ProgramNode
183
+ walk(node.statements, namespace, scope, nil, alias_context)
161
184
  when Prism::StatementsNode
162
- 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)) }
163
186
  when Prism::ClassNode, Prism::ModuleNode
164
187
  constant = self.class.constant(node.constant_path)
165
188
  name, valid = declaration_name(constant, namespace)
166
189
  location = loc(node, "worker_declaration")
167
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
168
195
  if !name && node.constant_path.is_a?(Prism::ConstantPathNode)
169
196
  @index.add(node.constant_path.name.to_s.encode(Encoding::UTF_8), location, selected: !@presence_only, ambiguous: true)
170
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
171
203
  new_scope = scope + [name || "<unsupported_class>"]
172
204
  if node.is_a?(Prism::ClassNode) && valid && !@presence_only
173
205
  body = node.body.is_a?(Prism::StatementsNode) ? node.body.body : []
@@ -184,24 +216,25 @@ module Jobcompat
184
216
  [@path, new_scope, self.class.normalize(node.constant_path.location.slice), include_tokens])
185
217
  end
186
218
  child_namespace = valid && namespace ? namespace + [name] : nil
187
- walk(node.superclass, namespace, scope, statement) if node.is_a?(Prism::ClassNode) && node.superclass
188
- walk(node.body, child_namespace, new_scope, nil) if node.body
219
+ walk(node.superclass, namespace, scope, statement, false, alias_statement) if node.is_a?(Prism::ClassNode) && node.superclass
220
+ body_context = alias_context && node.equal?(statement) && valid
221
+ walk(node.body, child_namespace, new_scope, nil, body_context, body_context ? nil : alias_statement) if node.body
189
222
  when Prism::DefNode
190
223
  method_scope = node.receiver ? [self.class.normalize(node.receiver.location.slice), node.name.to_s] : node.name.to_s
191
224
  child_scope = scope + [method_scope]
192
- walk(node.receiver, namespace, scope, statement) if node.receiver
193
- walk(node.parameters, namespace, child_scope, node.parameters) if node.parameters
194
- 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
195
228
  when Prism::SingletonClassNode
196
- walk(node.expression, namespace, scope, statement)
229
+ walk(node.expression, namespace, scope, statement, false, alias_statement)
197
230
  singleton_scope = scope + [["singleton_class", self.class.normalize(node.expression.location.slice)]]
198
- walk(node.body, namespace, singleton_scope, nil) if node.body
231
+ walk(node.body, namespace, singleton_scope, nil, false, alias_statement) if node.body
199
232
  when Prism::CallNode
200
233
  extract_call(node, namespace, scope, statement) unless @presence_only
201
- 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) }
202
235
  else
203
- constant_write(node, namespace) if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) } || PATH_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
204
- 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) }
205
238
  end
206
239
  end
207
240
 
@@ -213,10 +246,12 @@ module Jobcompat
213
246
  [((rooted || segments.length > 1) ? segments : (namespace.empty? ? [] : namespace.last.split("::")) + segments).join("::"), true]
214
247
  end
215
248
 
216
- def constant_write(node, namespace)
249
+ def constant_write(node, namespace, scope, statement, alias_context)
217
250
  if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
218
251
  prefix = namespace.nil? || namespace.empty? ? [] : namespace.last.split("::")
219
- @index.add((prefix + [node.name.to_s.encode(Encoding::UTF_8)]).join("::"), loc(node, "worker_declaration"),
252
+ name = (prefix + [node.name.to_s.encode(Encoding::UTF_8)]).join("::")
253
+ valid = !namespace.nil?
254
+ @index.add(name, loc(node, "worker_declaration"),
220
255
  selected: !@presence_only, ambiguous: namespace.nil?)
221
256
  else
222
257
  target = node.is_a?(Prism::ConstantPathTargetNode) ? node : node.target
@@ -227,6 +262,22 @@ module Jobcompat
227
262
  @index.add(target.name.to_s.encode(Encoding::UTF_8), loc(node, "worker_declaration"), selected: !@presence_only, ambiguous: true)
228
263
  end
229
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)
230
281
  end
231
282
 
232
283
  def sidekiq_include?(node)
@@ -387,28 +438,13 @@ module Jobcompat
387
438
  constant = self.class.constant(node)
388
439
  return [constant.first.join("::"), "client_constant", constant.last, nil] if constant
389
440
  if node.is_a?(Prism::StringNode)
390
- name = canonical_client_string_worker_name(node.unescaped)
441
+ name = CanonicalWorkerName.parse(node.unescaped)
391
442
  return [name, "exact_string", false, nil] if name
392
443
  return [nil, nil, nil, "unsupported_client_payload"]
393
444
  end
394
445
  [nil, nil, nil, "dynamic_client_class"]
395
446
  end
396
447
 
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
448
  def emit_call(node, namespace, scope, statement, method, receiver, resolution_mode, root, arity, reason)
413
449
  expression = self.class.normalize(node.location.slice)
414
450
  enclosing = self.class.normalize((statement || node).location.slice)
@@ -435,7 +471,7 @@ module Jobcompat
435
471
  selected = entries.select { |entry| @config.scan?(entry.path) }
436
472
  tracked = entries.select { |entry| entry.path.end_with?(".rb") }
437
473
  index = DefinedConstantIndex.new
438
- fragments, calls, unknowns, errors = [], [], [], []
474
+ fragments, calls, unknowns, errors, bindings, alias_candidates, ambiguous_bindings = [], [], [], [], [], [], []
439
475
  @repository.each_blob(selected) do |entry, bytes|
440
476
  path = entry.path
441
477
  begin
@@ -443,6 +479,9 @@ module Jobcompat
443
479
  fragments.concat(result.fragments)
444
480
  calls.concat(result.calls)
445
481
  unknowns.concat(result.unknowns)
482
+ bindings.concat(result.bindings)
483
+ alias_candidates.concat(result.alias_candidates)
484
+ ambiguous_bindings.concat(result.ambiguous_bindings)
446
485
  rescue Error => error
447
486
  errors << error
448
487
  end
@@ -484,8 +523,9 @@ module Jobcompat
484
523
  workers[name] = Contract.new(name, required, rest ? nil : required + optional, forwarding ? "forwarding" : "positional", signature, nil, locations, declarations, fingerprint_parts)
485
524
  end
486
525
  end
487
- RevisionSnapshot.new(label: label, ref: ref, sha: sha, workers: workers, calls: calls, unknowns: unknowns, index: index,
488
- files_scanned: selected.length, tracked_ruby: tracked)
526
+ aliases = AliasResolver.new(workers, bindings, alias_candidates, ambiguous_bindings).resolve
527
+ RevisionSnapshot.new(label: label, ref: ref, sha: sha, workers: workers, aliases: aliases, calls: calls, unknowns: unknowns, index: index,
528
+ files_scanned: selected.length, tracked_ruby: tracked, bindings: bindings)
489
529
  end
490
530
  end
491
531
  end
@@ -0,0 +1,35 @@
1
+ require "prism"
2
+
3
+ module Jobcompat
4
+ # Internal: the single authority for canonical String worker names, shared by
5
+ # producer analysis (Client String "class" values) and Config (ignore[].worker).
6
+ # Prism decides what a Ruby constant path is; no handwritten identifier grammar.
7
+ module CanonicalWorkerName
8
+ # Returns the canonical UTF-8 name when value is exactly one complete,
9
+ # non-root-qualified static constant path, or nil otherwise. The input is
10
+ # never trimmed or Unicode-normalized.
11
+ def self.parse(value)
12
+ return unless value.is_a?(String)
13
+ name = value.encode(Encoding::UTF_8)
14
+ return unless name.valid_encoding?
15
+ result = Prism.parse(name)
16
+ return unless result.errors.empty? && result.value.statements.body.one?
17
+ segments = segments(result.value.statements.body.first)
18
+ return unless segments
19
+ canonical = segments.join("::")
20
+ canonical if canonical == name
21
+ rescue EncodingError
22
+ nil
23
+ end
24
+
25
+ def self.segments(node)
26
+ case node
27
+ when Prism::ConstantReadNode then [node.name.to_s.encode(Encoding::UTF_8)]
28
+ when Prism::ConstantPathNode
29
+ parent = node.parent && segments(node.parent)
30
+ parent && parent + [node.name.to_s.encode(Encoding::UTF_8)]
31
+ end
32
+ end
33
+ private_class_method :segments
34
+ end
35
+ end