jobcompat 0.2.1 → 0.3.1

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,202 @@
1
+ # jobcompat v0.3.1 delta specification
2
+
3
+ Status: normative for 0.3.1. This document extends `spec-v0.3.md` (which extends
4
+ `spec-v0.2.1.md`, `spec-v0.2.md`, and `spec-v0.1.md`) with the optional text
5
+ `--explain` output. It adds display capability only. It changes no
6
+ compatibility decision, JSON contract, or exit status policy.
7
+
8
+ ## Version and schema contracts
9
+
10
+ Gem and CLI version: 0.3.1. Completed and failed JSON envelopes continue to
11
+ use `schema_version: 3` with the same fields and ordering as 0.3.0.
12
+ `.jobcompat.yml` continues to use config version 1. Ruby >= 3.3 and
13
+ prism >= 1.9, < 2 are unchanged, and no runtime dependency is added.
14
+
15
+ JC001–JC007 semantics, severities, comparison directions, finding precedence,
16
+ finding deduplication, semantic fingerprints, producer identity resolution,
17
+ alias resolution, presence budget, suppression matching, and exit status
18
+ policy are unchanged.
19
+
20
+ ## CLI contract
21
+
22
+ `jobcompat check --base REF --explain` is accepted, as are
23
+ `--format text --explain` and `--explain --format text` in either order.
24
+ Option order must not change results.
25
+
26
+ `--format json --explain` (either order) is rejected before any Git or
27
+ parse work: exit 2, category `config_error`, message
28
+ `--explain is supported only with --format text`, and the regular JSON
29
+ failed envelope with `schema_version: 3` on stdout. No stack trace or
30
+ internal exception reaches stderr.
31
+
32
+ `check --help` lists `--explain` with the description
33
+ `Explain the evidence behind each finding (text only)`. Top-level
34
+ `--help` and `--version` output is unchanged except the version string.
35
+
36
+ All existing failure categories (config_error, parse_error, Git errors,
37
+ internal_error) keep their existing streams, exit code 2, diagnostics
38
+ sanitization, and produce no Explain-specific additional diagnostics.
39
+
40
+ ## Explain scope
41
+
42
+ `--explain` explains only public findings that remain after targeted
43
+ suppression. Suppressed finding bodies receive no explanation. Internal
44
+ unreported candidates, producers that never became findings, repository
45
+ coverage, runtime queue contents, and hypothetical dynamic producers are out
46
+ of scope. Suppression audit lines, counts, order, and reasons are unchanged.
47
+
48
+ When no findings remain, the existing PASS (or PASS WITH WARNINGS) output is
49
+ emitted with no Explanation heading or section.
50
+
51
+ ## Rendering contract
52
+
53
+ For each remaining finding, an Explanation section is inserted between the
54
+ finding's source location list and `Suggested migration:`. All pre-existing
55
+ lines (severity, rule ID, worker, message, Revisions, Reason, Affected
56
+ directions, Risk, locations, remediation, summary, suppression audit) keep
57
+ their content, order, indentation, newlines, and trailing newline. The
58
+ section uses fixed headings:
59
+
60
+ ```text
61
+ Explanation:
62
+ Evidence:
63
+ Decision:
64
+ Boundary:
65
+ ```
66
+
67
+ `Evidence` contains one or more verified facts used by the decision.
68
+ `Decision` states the rule that produced the finding. `Boundary` states what
69
+ the evidence cannot prove, including conditional assumptions. Each finding
70
+ receives at most one Explanation. Every line passes the existing
71
+ `Formatter.safe_line` sanitization.
72
+
73
+ Without `--explain`, text output is byte-for-byte identical to 0.3.0 apart
74
+ from the version string, and JSON output is byte-for-byte identical to 0.3.0
75
+ apart from `tool.version`.
76
+
77
+ ## Per-rule explanation obligations
78
+
79
+ Each Explanation is built from the facts the Engine actually used at the
80
+ finding site.
81
+
82
+ - JC001: BASE producer's decided payload arity, the BASE consumer acceptance,
83
+ the HEAD consumer rejection, and—if `head_to_head` is among the
84
+ directions—a HEAD producer witness of the same arity. No HEAD witness is
85
+ claimed when the direction is absent. Boundary: retained queue contents are
86
+ not inspected.
87
+ - JC002: HEAD producer's decided arity, HEAD consumer acceptance, BASE
88
+ consumer rejection. Boundary: whether an old process consumes the relevant
89
+ queue, and when it retires, is not statically determined.
90
+ - JC003: HEAD producer arity and HEAD consumer rejection only; unrelated
91
+ base-revision evidence must not appear. JC001/JC003 precedence is
92
+ unchanged.
93
+ - JC004: BASE-recognized serialized identity, the conservative HEAD absence
94
+ proof, and provenance. `defined_unrecognized`, `outside_scan_scope`, and
95
+ `unverified` presence statuses are never described as absence. Boundary:
96
+ static absence does not mean the queue is empty.
97
+ - JC005: the decided HEAD serialized identity, HEAD recognition, and the
98
+ BASE absence proof. The rolling-deploy failure is conditional on an old
99
+ process consuming the relevant queue; it is never stated as unconditional.
100
+ A known producer identity suffices when payload arity is unknown. A
101
+ Class-object alias producer is explained under its terminal serialized
102
+ identity, and an exact Client String identity is never rewritten.
103
+ - JC006: BASE contract, HEAD contract, the removed acceptance ranges, and
104
+ the absence of a qualifying producer witness. Boundary: missing producer
105
+ witnesses do not prove absence of queued, scheduled, retried, historical,
106
+ or externally enqueued payloads; no safety or compatibility claim is made.
107
+ - JC007: the existing `unknown_reason`, revision(s), direction(s), and the
108
+ source provenance used for the diagnosis. Unknown or unsupported evidence
109
+ is never presented as proof of compatibility. No new unknown reason is
110
+ introduced. The alias reasons `alias_target_unresolved`, `alias_cycle`,
111
+ `alias_binding_conflict`, and `unsupported_alias_assignment` are reported
112
+ as-is, and an undetermined alias terminal is never presented as resolved.
113
+
114
+ ## Alias semantics
115
+
116
+ Serialized identity rules follow 0.3.0. A Class-object producer through an
117
+ alias serializes as the terminal Class name; an exact Client String keeps
118
+ its exact identity. Explanation text never substitutes a source alias token
119
+ for a serialized identity or vice versa.
120
+
121
+ For findings whose evidence involves an alias, Explanation may include one
122
+ line per relevant revision, using only the existing `AliasResolution`
123
+ result:
124
+
125
+ ```text
126
+ Alias (HEAD): OldJob -> MiddleJob -> NewJob (resolved).
127
+ Alias (HEAD): unresolved (alias_cycle).
128
+ ```
129
+
130
+ The Engine never re-walks the alias graph for explanations.
131
+
132
+ ## Proof model and isolation
133
+
134
+ Explanations are produced by the Engine at the sites where findings are
135
+ decided, using the producer groups, consumer contracts, presence results,
136
+ alias resolutions, and unknown roots already computed. Re-running
137
+ `Contract#accepts?`, re-walking alias graphs, re-proving absence,
138
+ re-extracting producers, or parsing finding messages to recover evidence is
139
+ prohibited.
140
+
141
+ The Engine exposes `evaluate(with_explanations: false)`. The default return
142
+ value is unchanged. With `with_explanations: true` the result additionally
143
+ carries an internal explanation sidecar keyed by finding object identity, so
144
+ multiple findings of the same rule and worker, JC007 deduplication groups,
145
+ and post-suppression survivors remain correctly paired. Object identity is
146
+ internal only; no identifier is printed or serialized. Findings and worker
147
+ rows never gain an `explanation` key, and the sidecar never reaches JSON.
148
+
149
+ `Formatter.text(envelope, explanations: nil)` renders the section only when
150
+ a non-nil sidecar maps the finding. `explanations: nil` keeps output
151
+ byte-identical. `Formatter.json` is unchanged.
152
+ Suppression continues to run after evaluation and before formatting;
153
+ explanations are displayed only for surviving findings.
154
+
155
+ Call-site counts in Evidence count distinct enqueue locations, keyed by
156
+ revision, path, line, and column. Location role is not part of that identity,
157
+ worker declarations and alias assignments are never counted, and multiple
158
+ arities or known/unknown records originating from one enqueue location count
159
+ once, so one bulk enqueue with several rows is one call site.
160
+
161
+ ## Determinism and security
162
+
163
+ For the same repository state, configuration, and command, repeated runs
164
+ produce byte-identical stdout, byte-identical stderr, and identical exit
165
+ codes, with and without `--explain`. Explanation construction avoids
166
+ insertion-order dependence, uses the existing stable location and chain
167
+ ordering, emits no timestamps, randomness, object IDs, or absolute temporary
168
+ paths, performs no Unicode normalization, and cannot inject raw newlines,
169
+ ANSI escapes, or Unicode format controls (existing `safe_line` escaping
170
+ applies). Error-path secret sanitization and stdout/stderr responsibilities
171
+ are unchanged.
172
+
173
+ ## Test acceptance criteria
174
+
175
+ - CLI: `--explain` accepted alone and with `--format text` in both orders;
176
+ `--format json --explain` rejected with exit 2, `config_error`, schema 3
177
+ failed envelope; help lists `--explain`; help/version unchanged; invalid
178
+ config, invalid ref, and parse errors keep existing failure contracts.
179
+ - JC001–JC007: per-rule evidence content, precedence, absence conservatism,
180
+ conditional rolling-deploy wording for JC005, no empty-queue claims for
181
+ JC006, unknown-not-compatible for JC007.
182
+ - Alias: resolved BASE/HEAD chains, finite chains, unsupported and unknown
183
+ aliases, direct workers, and Class-object versus exact String contrast.
184
+ - Suppression: suppressed findings receive no Explanation; surviving
185
+ findings do; audit unchanged.
186
+ - Backward compatibility: default text byte-identical to 0.3.0 fixtures
187
+ except the version string; JSON identical except `tool.version`;
188
+ normalized comparison must not mask findings, summary, provenance, or
189
+ unknown reasons.
190
+ - Determinism/security: double-run stdout/stderr/exit equality, control
191
+ characters, ANSI escapes, Unicode format controls, Unicode worker names,
192
+ newlines in refs, dirty worktree neutrality, no source execution, no
193
+ network.
194
+ - No findings: exit 0 with no added Explanation output; warning-only exit 0;
195
+ any errors exit 1.
196
+
197
+ ## Non-goals
198
+
199
+ No new compatibility rule, severity, direction, unknown reason, JSON field,
200
+ config key, dependency, queue/runtime simulation, heuristic rename
201
+ detection, or change to existing normative specifications. Explanations do
202
+ not restate suppressed findings and do not estimate runtime behavior.
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