jobcompat 0.3.0 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b1df789562ba30ff3474a112bd44e602d68b833b7d34c92b936f8e508e20484
4
- data.tar.gz: ba09c1c7f2ce1f22d44cf690559e395584be8492c5abbbad85d492281b6849cd
3
+ metadata.gz: fe462bfac086361d10bc4be2bb535045c131d0cbabb9037b437789684665eb29
4
+ data.tar.gz: ecc7ee3319c763957771f3eff973ae3f7ace1b5deb43fda27de694b9fea8531f
5
5
  SHA512:
6
- metadata.gz: c4e50e2e13f1bc1d3a3e667b63a3865bd01260f8b9bd462db724c0aa44641fd555f191a3059548df2ca408e13a081d3daeb993e4a805a7b9617d79034a36643b
7
- data.tar.gz: aff92fbf9b66f5320958497d79a07fbf0defe4b56f1da444fddf69340478f72677e2cad601c5e9951d4de5e76a27f71095ba741e2c0d437be42a8eec75f9413a
6
+ metadata.gz: 137621193e3cbbad8cc1c368718390ee4c65e7c0ed37a5ba2a2ae798b24d4fbd0aa05ba26673e26b968c44bb89cc36b6cfc2146bc11638eb2ead7e97d19c31a1
7
+ data.tar.gz: e91710462947c4376ccc8fcc3f3f167fc7dfd2e21e1fa9d61d0197d69538f4707aec557fb662c6a5ec2785bb4688744866545bbcd395734afb9dc3a97a15a55b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 - 2026-11-07
4
+
5
+ - Add optional `--explain` text output showing the evidence, decision, and boundary behind each surviving JC001–JC007 finding, including alias provenance for serialized identities.
6
+ - Compatibility decisions, default text output, and JSON schema 3 content are unchanged; combining `--explain` with `--format json` fails with a config error before analysis.
7
+
3
8
  ## 0.3.0 - 2026-10-05
4
9
 
5
10
  - Recognize static Sidekiq compatibility aliases, including finite chains and supported namespaces, to preserve old serialized identities across worker renames.
data/README.md CHANGED
@@ -36,10 +36,13 @@ From a Git repository containing native Sidekiq jobs:
36
36
  ```sh
37
37
  jobcompat check --base origin/main
38
38
  jobcompat check --base v0.1.0 --head HEAD --format json
39
+ jobcompat check --base origin/main --explain
39
40
  ```
40
41
 
41
42
  Both refs resolve to commits. Staged and uncommitted changes are ignored. The command never checks out another revision or changes the working tree.
42
43
 
44
+ `--explain` appends an `Explanation` section—evidence, decision, and boundary—to each remaining finding in text output. It shows why a rule fired from the static evidence jobcompat already collected: it runs no additional compatibility checks, cannot be combined with `--format json` (exit 2 config error), reports only static source evidence, and never turns an `unknown` result into a safety claim.
45
+
43
46
  ## Example output
44
47
 
45
48
  For a base one-argument producer and a HEAD worker that requires two arguments, text output includes:
data/SECURITY.md CHANGED
@@ -2,4 +2,8 @@
2
2
 
3
3
  If you find a vulnerability, use this repository's GitHub private vulnerability reporting. Please avoid posting an exploitable report in a public issue.
4
4
 
5
- Supported release: 0.1.x after publication. Security fixes will be released as a new gem version rather than replacing an existing one.
5
+ ## Supported releases
6
+
7
+ The latest published minor release series is supported for security fixes. Older versions may receive fixes at the maintainer's discretion.
8
+
9
+ Security fixes are published as new gem versions. Existing published gem versions are not replaced.
@@ -0,0 +1,54 @@
1
+ # jobcompat v0.3.1 implementation plan
2
+
3
+ Baseline: `02751a57e21da3f548f6d6442b17a438026ef9e1` (v0.3.0). All changes
4
+ stay uncommitted for independent review.
5
+
6
+ ## Change order
7
+
8
+ 1. `lib/jobcompat/explanation.rb` (new): `Explanation = Data.define(:evidence, :decision, :boundary)` plus a private builder module used by the Engine. Evidence entries, decision, and boundary are plain frozen strings; rendering responsibility stays with the Formatter.
9
+ 2. `lib/jobcompat/engine.rb`: `evaluate(with_explanations: false)`. At each
10
+ finding site (JC001, JC002, JC003, JC004, JC005, JC006, presence-unknown
11
+ JC007, and the aggregated JC007 roots) build an `Explanation` from the
12
+ producer group, contracts, presence result, alias resolution, and unknown
13
+ root already in scope, and store it in a
14
+ `{}.compare_by_identity` sidecar keyed by the finding hash object. The
15
+ sidecar is returned as `explanations:` only when requested. Finding and
16
+ worker rows are not modified.
17
+ 3. `lib/jobcompat/formatter.rb`: `text(envelope, explanations: nil)`. When a
18
+ non-nil sidecar maps the finding, render the fixed `Explanation:` /
19
+ `Evidence:` / `Decision:` / `Boundary:` block between the location lines
20
+ and `Suggested migration:`. `explanations: nil` keeps output
21
+ byte-identical. `json` untouched.
22
+ 4. `lib/jobcompat/cli.rb`: parse `--explain` (default false); reject
23
+ `--explain` with non-text format as `config_error` before repository
24
+ access; pass `with_explanations:` to `evaluate` and `explanations:` to
25
+ `Formatter.text` for the completed text path only.
26
+ 5. `lib/jobcompat/version.rb` -> 0.3.1; `Gemfile.lock` path spec version.
27
+ 6. `docs/spec-v0.3.1.md` (normative delta), this plan, README quick-start and
28
+ explanation notes, `CHANGELOG.md` `## 0.3.1 - Unreleased`.
29
+
30
+ ## Test order
31
+
32
+ 1. Unit `test/unit/explanation_test.rb`: Explanation VO shape; formatter
33
+ parity (nil sidecar byte-identical, unknown finding -> no section);
34
+ sanitization of hostile strings.
35
+ 2. Integration `test/integration/explain_command_test.rb`: CLI contract
36
+ (accept/combos/order/json rejection/failed envelope schema 3/help);
37
+ per-rule content for JC001 (both direction variants, alias-carried
38
+ identity), JC002, JC003 (no base leakage), JC004 (absence only;
39
+ outside-scope fixture must not claim absence), JC005 (conditional
40
+ rolling-deploy wording, Class-object terminal identity, exact Client
41
+ String identity), JC006 (no empty-queue claim), JC007 (alias cycle,
42
+ unresolved, splat producer, keyword contract); suppression behavior;
43
+ zero-finding behavior; determinism double-run; default-output regression
44
+ against committed expectations derived from the frozen 0.3.0 baselines.
45
+ 3. Full suite `rake test`; baseline runner `compare` (byte-identical after
46
+ version normalization only).
47
+ 4. `gem build`, package content inspection, isolated install, CLI smoke.
48
+
49
+ ## Regression evidence
50
+
51
+ Frozen 0.3.0 outputs for JC001–JC007 (including alias-carried, client-string,
52
+ and outside-scope variants), resolved alias rename, targeted suppression, and
53
+ a no-findings comparison were captured before implementation and are
54
+ re-compared after; only the version string may differ.
@@ -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/lib/jobcompat/cli.rb CHANGED
@@ -9,6 +9,7 @@ module Jobcompat
9
9
  def initialize(args, stdout, stderr, directory)
10
10
  @args, @stdout, @stderr, @directory = args.dup, stdout, stderr, directory
11
11
  @base, @head, @format, @config_path = nil, "HEAD", "text", nil
12
+ @explain = false
12
13
  @base_sha = @head_sha = @loaded_config = nil
13
14
  end
14
15
 
@@ -22,12 +23,14 @@ module Jobcompat
22
23
  options.on("--head REF", "Head commit-ish (default HEAD)") { |value| @head = value }
23
24
  options.on("--format FORMAT", "text or json") { |value| @format = value }
24
25
  options.on("--config PATH", "Config YAML path") { |value| @config_path = value }
26
+ options.on("--explain", "Explain the evidence behind each finding (text only)") { @explain = true }
25
27
  options.on("-h", "--help", "Show help") { @stdout.puts(options); return 0 }
26
28
  end
27
29
  parser.parse!(@args)
28
30
  raise Error.new("Unexpected arguments: #{@args.join(' ')}", category: "config_error") unless @args.empty?
29
31
  raise Error.new("--base is required", category: "config_error") unless @base && !@base.empty?
30
32
  raise Error.new("--format must be text or json", category: "config_error") unless %w[text json].include?(@format)
33
+ raise Error.new("--explain is supported only with --format text", category: "config_error") if @explain && @format != "text"
31
34
  repository = GitRepository.new(@directory)
32
35
  config = Config.load(root: repository.root, explicit: @config_path, invocation_dir: @directory)
33
36
  @loaded_config = config.display_path
@@ -57,7 +60,7 @@ module Jobcompat
57
60
  requests = engine.presence_requests
58
61
  head.check_presence(requests["head"], repository, config)
59
62
  base.check_presence(requests["base"], repository, config)
60
- result = engine.evaluate
63
+ result = engine.evaluate(with_explanations: @explain)
61
64
  findings, suppressions = config.suppressions(result[:findings])
62
65
  summary = {
63
66
  errors: findings.count { |item| item[:severity] == "error" }, warnings: findings.count { |item| item[:severity] == "warning" },
@@ -67,7 +70,13 @@ module Jobcompat
67
70
  enqueue_calls: {base: base.calls.length, head: head.calls.length,
68
71
  unknown: engine.calls.values.flatten.count { |call| call[:reason] }}
69
72
  }
70
- @stdout.write(Formatter.public_send(@format, envelope("completed", findings, suppressions, result[:workers], [], summary)))
73
+ completed = envelope("completed", findings, suppressions, result[:workers], [], summary)
74
+ output = if @format == "json"
75
+ Formatter.json(completed)
76
+ else
77
+ Formatter.text(completed, explanations: @explain ? result[:explanations] : nil)
78
+ end
79
+ @stdout.write(output)
71
80
  summary[:errors].positive? ? 1 : 0
72
81
  rescue OptionParser::ParseError => error
73
82
  failure(Error.new(error.message, category: "config_error"))
@@ -32,8 +32,9 @@ module Jobcompat
32
32
  @presence_requests
33
33
  end
34
34
 
35
- def evaluate
35
+ def evaluate(with_explanations: false)
36
36
  findings = []
37
+ explanations = {}.compare_by_identity
37
38
  workers = @names.map do |name|
38
39
  b = base.consumer(name)
39
40
  h = head.consumer(name)
@@ -43,22 +44,32 @@ module Jobcompat
43
44
  head_presence = presence(head, name, h, presence_requests["head"].include?(name))
44
45
  if b && !h
45
46
  if head_presence == "absent"
46
- findings << finding("JC004", name, ["base", "head"], ["base_to_head"], nil, nil,
47
- "The serialized worker identity is absent from HEAD tracked Ruby source.",
48
- "Queued, retried, or scheduled jobs may still reference this class name.",
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))
51
- else
52
- findings << presence_unknown(name, head_presence, b.declaration_locations + head.index.locations(name), "base_to_head", "head") unless head.aliases.key?(name)
47
+ item = finding("JC004", name, ["base", "head"], ["base_to_head"], nil, nil,
48
+ "The serialized worker identity is absent from HEAD tracked Ruby source.",
49
+ "Queued, retried, or scheduled jobs may still reference this class name.",
50
+ ["Keep the worker class or a supported compatibility alias until retained jobs can no longer run."],
51
+ b.declaration_locations + contract_proof(b) + producer_proof(hc))
52
+ findings << item
53
+ explanations[item] = ExplanationBuilder.jc004(worker: name, base_contract: b, head_producers: hc,
54
+ aliases: alias_provenance(name)) if with_explanations
55
+ elsif !head.aliases.key?(name)
56
+ item = presence_unknown(name, head_presence, b.declaration_locations + head.index.locations(name), "base_to_head", "head")
57
+ findings << item
58
+ explanations[item] = ExplanationBuilder.presence_unknown(worker: name, status: head_presence, revision: "head") if with_explanations
53
59
  end
54
60
  elsif h && !b && !hc.empty?
55
61
  if base_presence == "absent"
56
- findings << finding("JC005", name, ["base", "head"], ["head_to_base"], nil, nil,
57
- "HEAD can enqueue a serialized worker identity absent from base tracked Ruby source.",
58
- "If an old Sidekiq process can consume this job during the rolling deployment, it cannot resolve the new worker class.",
59
- ["Deploy the worker class or compatibility alias before activating its producer."], h.declaration_locations + producer_proof(hc))
60
- else
61
- findings << presence_unknown(name, base_presence, h.declaration_locations + base.index.locations(name), "head_to_base", "base") unless base.aliases.key?(name)
62
+ item = finding("JC005", name, ["base", "head"], ["head_to_base"], nil, nil,
63
+ "HEAD can enqueue a serialized worker identity absent from base tracked Ruby source.",
64
+ "If an old Sidekiq process can consume this job during the rolling deployment, it cannot resolve the new worker class.",
65
+ ["Deploy the worker class or compatibility alias before activating its producer."], h.declaration_locations + producer_proof(hc))
66
+ findings << item
67
+ explanations[item] = ExplanationBuilder.jc005(worker: name, head_contract: h, head_calls: hc,
68
+ aliases: alias_provenance(name)) if with_explanations
69
+ elsif !base.aliases.key?(name)
70
+ item = presence_unknown(name, base_presence, h.declaration_locations + base.index.locations(name), "head_to_base", "base")
71
+ findings << item
72
+ explanations[item] = ExplanationBuilder.presence_unknown(worker: name, status: base_presence, revision: "base") if with_explanations
62
73
  end
63
74
  end
64
75
 
@@ -72,10 +83,13 @@ module Jobcompat
72
83
  directions = ["base_to_head"]
73
84
  directions << "head_to_head" unless head_same.empty?
74
85
  locations = producer_proof(group) + contract_proof(b) + contract_proof(h) + producer_proof(head_same)
75
- findings << finding("JC001", name, head_same.empty? ? ["base", "head"] : ["base", "head"], directions, arity, nil,
76
- "Base emits #{arity} #{argument_word(arity)}, but the HEAD worker accepts #{h.display_range}.",
77
- "Jobs queued by the base revision may fail after deployment.",
78
- ["Make the HEAD worker accept old payloads.", "Narrow only after queue, retry, and schedule retention is handled."], locations)
86
+ item = finding("JC001", name, head_same.empty? ? ["base", "head"] : ["base", "head"], directions, arity, nil,
87
+ "Base emits #{arity} #{argument_word(arity)}, but the HEAD worker accepts #{h.display_range}.",
88
+ "Jobs queued by the base revision may fail after deployment.",
89
+ ["Make the HEAD worker accept old payloads.", "Narrow only after queue, retry, and schedule retention is handled."], locations)
90
+ findings << item
91
+ explanations[item] = ExplanationBuilder.jc001(arity: arity, base_contract: b, head_contract: h, head_witness: !head_same.empty?,
92
+ aliases: alias_provenance(name)) if with_explanations
79
93
  proven_narrow = true
80
94
  end
81
95
  end
@@ -84,17 +98,23 @@ module Jobcompat
84
98
  head_groups.sort.each do |arity, group|
85
99
  if !h.accepts?(arity)
86
100
  next if findings.any? { |item| item[:rule_id] == "JC001" && item[:worker] == name && item[:payload_arity] == arity }
87
- findings << finding("JC003", name, ["head"], ["head_to_head"], arity, nil,
88
- "HEAD emits #{arity} #{argument_word(arity)}, but its worker accepts #{h.display_range}.",
89
- "Current HEAD jobs can fail when executed.", ["Align the enqueue payload with the HEAD perform signature."],
90
- producer_proof(group) + contract_proof(h))
101
+ item = finding("JC003", name, ["head"], ["head_to_head"], arity, nil,
102
+ "HEAD emits #{arity} #{argument_word(arity)}, but its worker accepts #{h.display_range}.",
103
+ "Current HEAD jobs can fail when executed.", ["Align the enqueue payload with the HEAD perform signature."],
104
+ producer_proof(group) + contract_proof(h))
105
+ findings << item
106
+ explanations[item] = ExplanationBuilder.jc003(arity: arity, head_contract: h,
107
+ aliases: alias_provenance(name, head)) if with_explanations
91
108
  current_removed_witness ||= b&.known? && b.accepts?(arity)
92
109
  elsif b&.known? && !b.accepts?(arity)
93
- findings << finding("JC002", name, ["base", "head"], ["head_to_base"], arity, nil,
94
- "HEAD emits #{arity} #{argument_word(arity)}, but the base worker accepts #{b.display_range}.",
95
- "A new producer can enqueue work that an old worker cannot execute during a rolling deploy.",
96
- ["Deploy the optional worker argument first.", "Start enqueueing the new argument in a later release."],
97
- producer_proof(group) + contract_proof(b) + contract_proof(h))
110
+ item = finding("JC002", name, ["base", "head"], ["head_to_base"], arity, nil,
111
+ "HEAD emits #{arity} #{argument_word(arity)}, but the base worker accepts #{b.display_range}.",
112
+ "A new producer can enqueue work that an old worker cannot execute during a rolling deploy.",
113
+ ["Deploy the optional worker argument first.", "Start enqueueing the new argument in a later release."],
114
+ producer_proof(group) + contract_proof(b) + contract_proof(h))
115
+ findings << item
116
+ explanations[item] = ExplanationBuilder.jc002(arity: arity, base_contract: b, head_contract: h,
117
+ aliases: alias_provenance(name)) if with_explanations
98
118
  end
99
119
  end
100
120
  end
@@ -102,11 +122,14 @@ module Jobcompat
102
122
  unless h.superset_of?(b) || proven_narrow || current_removed_witness
103
123
  removed = removed_arity_ranges(b, h)
104
124
  label = removed.length == 1 && removed.first.match?(/\A\d+\z/) ? "arity" : "arities"
105
- findings << finding("JC006", name, ["base", "head"], ["base_to_head"], nil, nil,
106
- "The HEAD worker removed #{label} #{removed.join(', ')} from the base contract; no qualifying producer witness was found.",
107
- "No repository producer callsite found does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists.",
108
- ["Keep the broader signature through the retention window, or document a proven drain."],
109
- contract_proof(b) + contract_proof(h))
125
+ item = finding("JC006", name, ["base", "head"], ["base_to_head"], nil, nil,
126
+ "The HEAD worker removed #{label} #{removed.join(', ')} from the base contract; no qualifying producer witness was found.",
127
+ "No repository producer callsite found does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists.",
128
+ ["Keep the broader signature through the retention window, or document a proven drain."],
129
+ contract_proof(b) + contract_proof(h))
130
+ findings << item
131
+ explanations[item] = ExplanationBuilder.jc006(worker: name, base_contract: b, head_contract: h, removed: removed,
132
+ aliases: alias_provenance(name)) if with_explanations
110
133
  end
111
134
  end
112
135
 
@@ -121,9 +144,11 @@ module Jobcompat
121
144
  head_to_base: cell(hc, b, base_presence), head_to_head: cell(hc, h, head_presence)}
122
145
  }
123
146
  end
124
- findings.concat(unknown_findings)
147
+ findings.concat(unknown_findings(explanations: with_explanations ? explanations : nil))
125
148
  findings = findings.sort_by { |item| finding_sort_key(item) }
126
- {findings: findings, workers: workers}
149
+ result = {findings: findings, workers: workers}
150
+ result[:explanations] = explanations if with_explanations
151
+ result
127
152
  end
128
153
 
129
154
  private
@@ -189,6 +214,17 @@ module Jobcompat
189
214
 
190
215
  def producer_proof(group) = group.flat_map { |call| call[:locations] }
191
216
 
217
+ def alias_provenance(name, *snapshots)
218
+ snapshots = [base, head] if snapshots.empty?
219
+ snapshots.filter_map do |snapshot|
220
+ binding = snapshot.aliases[name]
221
+ next unless binding
222
+ chain = binding.chain.join(" -> ")
223
+ binding.resolved? ? "Alias (#{snapshot.label.upcase}): #{chain} (resolved)." :
224
+ "Alias (#{snapshot.label.upcase}): #{chain.length > 1 ? chain : "unresolved"} (#{binding.unknown_reason})."
225
+ end
226
+ end
227
+
192
228
  def contract_proof(contract)
193
229
  return contract.locations if base.aliases.key?(contract.name) || head.aliases.key?(contract.name)
194
230
  contract.locations.select { |location| location.role == "consumer" }
@@ -236,7 +272,7 @@ module Jobcompat
236
272
  ["Restore a directly recognized worker declaration, or inspect the deployment manually."], locations)
237
273
  end
238
274
 
239
- def unknown_findings
275
+ def unknown_findings(explanations: nil)
240
276
  roots = []
241
277
  [base, head].each do |snapshot|
242
278
  @names.each do |name|
@@ -251,7 +287,8 @@ module Jobcompat
251
287
  producer_locations = calls[snapshot.label].select { |call| call[:identity_reason] && call[:attributed_worker] == name }.flat_map { |call| call[:locations] }
252
288
  roots << {revision: snapshot.label, kind: "consumer_alias", reason: binding.unknown_reason, worker: name,
253
289
  locations: binding.locations + producer_locations,
254
- parts: ["consumer_alias", binding.unknown_reason, name, binding.fingerprint_parts, binding.chain], offset: 0}
290
+ parts: ["consumer_alias", binding.unknown_reason, name, binding.fingerprint_parts, binding.chain],
291
+ chain: binding.chain, offset: 0}
255
292
  end
256
293
  snapshot.unknowns.each do |item|
257
294
  roots << {revision: snapshot.label, kind: item.kind, reason: item.reason, worker: item.worker,
@@ -294,9 +331,11 @@ module Jobcompat
294
331
  message = alias_reason ? alias_message(first[:worker], first[:reason]) : "#{first[:reason]} prevents a complete positional compatibility check."
295
332
  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
333
  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."]
297
- finding("JC007", first[:worker], group.map { |root| root[:revision] }.uniq, directions, nil, first[:reason],
298
- message, risk, remediation,
299
- group.flat_map { |root| root[:locations] })
334
+ item = finding("JC007", first[:worker], group.map { |root| root[:revision] }.uniq, directions, nil, first[:reason],
335
+ message, risk, remediation,
336
+ group.flat_map { |root| root[:locations] })
337
+ explanations[item] = ExplanationBuilder.unknown_group(roots: group, message: message) if explanations
338
+ item
300
339
  end
301
340
  end
302
341
 
@@ -0,0 +1,144 @@
1
+ module Jobcompat
2
+ Explanation = Data.define(:evidence, :decision, :boundary)
3
+
4
+ module ExplanationBuilder
5
+ REVISION_LABELS = {"base" => "BASE", "head" => "HEAD"}.freeze
6
+
7
+ module_function
8
+
9
+ def jc001(arity:, base_contract:, head_contract:, head_witness:, aliases:)
10
+ evidence = [
11
+ "BASE producer emits #{arity} positional #{argument_word(arity)}.",
12
+ "BASE consumer accepts #{arity} positional #{argument_word(arity)} (#{range(base_contract)}).",
13
+ "HEAD consumer accepts #{range(head_contract)} positional #{range_word(head_contract)}, rejecting #{arity}."
14
+ ]
15
+ evidence << "HEAD producer also emits #{arity} positional #{argument_word(arity)}, enqueuing the same payload shape." if head_witness
16
+ decision = "A payload accepted in BASE (#{arity} positional #{argument_word(arity)}) is rejected by the HEAD worker (#{range(head_contract)})."
17
+ decision << " HEAD producers still enqueue the same arity." if head_witness
18
+ build(evidence + aliases, decision,
19
+ "Static arity evidence; actual retained queue contents are not inspected.")
20
+ end
21
+
22
+ def jc002(arity:, base_contract:, head_contract:, aliases:)
23
+ evidence = [
24
+ "HEAD producer emits #{arity} positional #{argument_word(arity)}.",
25
+ "HEAD consumer accepts #{range(head_contract)} positional #{range_word(head_contract)}.",
26
+ "BASE consumer accepts #{range(base_contract)} positional #{range_word(base_contract)}, rejecting #{arity}."
27
+ ]
28
+ build(evidence + aliases,
29
+ "A payload enqueued by HEAD (#{arity} positional #{argument_word(arity)}) would be rejected by the base worker if an old process executes it during the rolling deployment.",
30
+ "Whether an old process consumes this queue, and when it retires, is not determined by static analysis.")
31
+ end
32
+
33
+ def jc003(arity:, head_contract:, aliases:)
34
+ evidence = [
35
+ "HEAD producer emits #{arity} positional #{argument_word(arity)}.",
36
+ "HEAD consumer accepts #{range(head_contract)} positional #{range_word(head_contract)}, rejecting #{arity}."
37
+ ]
38
+ build(evidence + aliases,
39
+ "HEAD's own producer and consumer disagree on payload arity.",
40
+ "Static arity evidence within HEAD; base-revision evidence does not affect this finding.")
41
+ end
42
+
43
+ def jc004(worker:, base_contract:, head_producers:, aliases:)
44
+ evidence = [consumer_fact("base", worker, base_contract),
45
+ "HEAD conservative presence check across tracked Ruby source proved #{worker} absent."]
46
+ evidence << "HEAD still contains #{call_sites(distinct_call_sites(head_producers))} referencing this identity." if head_producers.any?
47
+ build(evidence + aliases,
48
+ "A retained job serialized as #{worker} cannot be resolved to a worker by HEAD tracked Ruby source.",
49
+ "Static absence does not prove the queue is empty; queued, retried, scheduled, historical, or externally enqueued jobs may still reference this identity.")
50
+ end
51
+
52
+ def jc005(worker:, head_contract:, head_calls:, aliases:)
53
+ evidence = ["HEAD producer can enqueue the serialized worker identity #{worker} (#{call_sites(distinct_call_sites(head_calls))})."]
54
+ head_calls.map { |call| call[:attributed_worker] }.compact.uniq.sort.reject { |name| name == worker }.each do |name|
55
+ evidence << "HEAD Class-object producer #{name} serializes as the terminal identity #{worker}."
56
+ end
57
+ evidence << "HEAD Client String producer keeps the exact identity #{worker}." if head_calls.any? { |call| call[:raw].resolution_mode == "exact_string" }
58
+ known = head_calls.filter_map { |call| call[:arity] unless call[:reason] }.uniq.sort
59
+ evidence << "Known HEAD payload arities for this identity: #{known.join(', ')}." if known.any?
60
+ evidence << consumer_fact("head", worker, head_contract)
61
+ evidence << "BASE conservative presence check across tracked Ruby source proved #{worker} absent."
62
+ build(evidence + aliases,
63
+ "If an old Sidekiq process consumes this job's queue during the rolling deployment, it cannot resolve the serialized identity #{worker}.",
64
+ "This does not prove unconditional failure; the risk is conditional on an old process consuming the relevant queue before retirement, which static analysis cannot determine.")
65
+ end
66
+
67
+ def jc006(worker:, base_contract:, head_contract:, removed:, aliases:)
68
+ evidence = [
69
+ consumer_fact("base", worker, base_contract),
70
+ consumer_fact("head", worker, head_contract),
71
+ "HEAD no longer accepts #{removed.length == 1 && removed.first.match?(/\A\d+\z/) ? "arity" : "arities"} #{removed.join(', ')}.",
72
+ "No qualifying producer witness was found for the removed range in either revision."
73
+ ]
74
+ build(evidence + aliases,
75
+ "The worker contract narrowed from #{range(base_contract)} to #{range(head_contract)} without a qualifying producer witness.",
76
+ "No repository producer witness does not prove that no queued, scheduled, retried, historical, or externally enqueued payload exists.")
77
+ end
78
+
79
+ def presence_unknown(worker:, status:, revision:)
80
+ label = REVISION_LABELS.fetch(revision)
81
+ fact = case status
82
+ when "defined_unrecognized" then "#{worker} is defined in #{label} selected source, but is not recognized as a Sidekiq worker."
83
+ when "outside_scan_scope" then "#{worker} is declared only outside the scan scope in #{label}."
84
+ when "unverified" then "Presence of #{worker} in #{label} could not be verified within the analysis budget."
85
+ end
86
+ build([fact],
87
+ "The Sidekiq contract for #{worker} is not recognized, so this transition cannot be proven.",
88
+ "Unknown is not a proof of compatibility; the affected evidence was excluded from the compatibility decision.")
89
+ end
90
+
91
+ def unknown_group(roots:, message:)
92
+ build(roots.map { |root| root_fact(root) }, message,
93
+ "Unknown or unsupported evidence is not a proof of compatibility; the affected calls or contracts were excluded from the compatibility decision.")
94
+ end
95
+
96
+ def root_fact(root)
97
+ label = REVISION_LABELS.fetch(root[:revision])
98
+ case root[:kind]
99
+ when "consumer_contract" then "#{label}: the consumer contract for #{root[:worker]} is unknown (#{root[:reason]})."
100
+ when "consumer_alias"
101
+ chain = root[:chain]
102
+ path = chain && chain.length > 1 ? chain.join(" -> ") : "unresolved"
103
+ "Alias (#{label}): #{path} (#{root[:reason]})."
104
+ when "worker_identity" then "#{label}: a Sidekiq-including class is declared through an unsupported constant path."
105
+ when "producer_receiver"
106
+ root[:worker] ? "#{label}: the producer receiver for #{root[:worker]} could not be resolved (#{root[:reason]})." :
107
+ "#{label}: a producer receiver could not be resolved (#{root[:reason]})."
108
+ when "producer_arity"
109
+ root[:worker] ? "#{label}: the payload arity for #{root[:worker]} could not be determined (#{root[:reason]})." :
110
+ "#{label}: a producer payload arity could not be determined (#{root[:reason]})."
111
+ end
112
+ end
113
+
114
+ def consumer_fact(revision, worker, contract)
115
+ contract.known? ?
116
+ "#{REVISION_LABELS.fetch(revision)} consumer #{worker} accepts #{range(contract)} positional #{range_word(contract)}." :
117
+ "#{REVISION_LABELS.fetch(revision)} consumer #{worker} has an unknown contract (#{contract.unknown_reason})."
118
+ end
119
+
120
+ def range_word(contract)
121
+ contract.display_range.match?(/\A\d+\z/) ? argument_word(contract.display_range.to_i) : "arguments"
122
+ end
123
+
124
+ def range(contract)
125
+ contract.display_range
126
+ end
127
+
128
+ def argument_word(number)
129
+ number == 1 ? "argument" : "arguments"
130
+ end
131
+
132
+ def call_sites(number)
133
+ "#{number} call site#{number == 1 ? '' : 's'}"
134
+ end
135
+
136
+ def distinct_call_sites(calls)
137
+ calls.map { |call| call[:location] }.uniq { |location| [location.revision, location.path, location.line, location.column] }.length
138
+ end
139
+
140
+ def build(evidence, decision, boundary)
141
+ Explanation.new(evidence.freeze, decision, boundary).freeze
142
+ end
143
+ end
144
+ end
@@ -15,7 +15,7 @@ module Jobcompat
15
15
  end
16
16
  end
17
17
 
18
- def self.text(envelope)
18
+ def self.text(envelope, explanations: nil)
19
19
  return envelope[:diagnostics].map { |item| safe_line("#{item[:category]}: #{item[:message]}") }.join("\n") + "\n" if envelope[:status] == "failed"
20
20
  base = envelope[:comparison][:base]
21
21
  head = envelope[:comparison][:head]
@@ -35,6 +35,16 @@ module Jobcompat
35
35
  finding[:locations].each do |location|
36
36
  lines << " #{location.revision} #{location.path}:#{location.line}:#{location.column} (#{location.role})"
37
37
  end
38
+ explanation = explanations && explanations[finding]
39
+ if explanation
40
+ lines << " Explanation:"
41
+ lines << " Evidence:"
42
+ explanation.evidence.each { |fact| lines << " #{fact}" }
43
+ lines << " Decision:"
44
+ lines << " #{explanation.decision}"
45
+ lines << " Boundary:"
46
+ lines << " #{explanation.boundary}"
47
+ end
38
48
  lines << " Suggested migration:"
39
49
  finding[:remediation].each_with_index { |step, index| lines << " #{index + 1}. #{step}" }
40
50
  lines << ""
@@ -1,3 +1,3 @@
1
1
  module Jobcompat
2
- VERSION = "0.3.0"
2
+ VERSION = "0.3.1"
3
3
  end
data/lib/jobcompat.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "jobcompat/canonical_worker_name"
4
4
  require_relative "jobcompat/config"
5
5
  require_relative "jobcompat/git_repository"
6
6
  require_relative "jobcompat/analysis"
7
+ require_relative "jobcompat/explanation"
7
8
  require_relative "jobcompat/engine"
8
9
  require_relative "jobcompat/formatter"
9
10
  require_relative "jobcompat/cli"
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.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - jobcompat contributors
@@ -44,6 +44,7 @@ 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.1.md
47
48
  - docs/implementation-plan-v0.3.md
48
49
  - docs/implementation-plan.md
49
50
  - docs/release-checklist-v0.1.0.md
@@ -57,6 +58,7 @@ files:
57
58
  - docs/spec-v0.1.md
58
59
  - docs/spec-v0.2.1.md
59
60
  - docs/spec-v0.2.md
61
+ - docs/spec-v0.3.1.md
60
62
  - docs/spec-v0.3.md
61
63
  - exe/jobcompat
62
64
  - lib/jobcompat.rb
@@ -67,6 +69,7 @@ files:
67
69
  - lib/jobcompat/config.rb
68
70
  - lib/jobcompat/engine.rb
69
71
  - lib/jobcompat/errors.rb
72
+ - lib/jobcompat/explanation.rb
70
73
  - lib/jobcompat/formatter.rb
71
74
  - lib/jobcompat/git_repository.rb
72
75
  - lib/jobcompat/version.rb