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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +3 -0
- data/SECURITY.md +5 -1
- data/docs/implementation-plan-v0.3.1.md +54 -0
- data/docs/spec-v0.3.1.md +202 -0
- data/lib/jobcompat/cli.rb +11 -2
- data/lib/jobcompat/engine.rb +78 -39
- data/lib/jobcompat/explanation.rb +144 -0
- data/lib/jobcompat/formatter.rb +11 -1
- data/lib/jobcompat/version.rb +1 -1
- data/lib/jobcompat.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fe462bfac086361d10bc4be2bb535045c131d0cbabb9037b437789684665eb29
|
|
4
|
+
data.tar.gz: ecc7ee3319c763957771f3eff973ae3f7ace1b5deb43fda27de694b9fea8531f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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.
|
data/docs/spec-v0.3.1.md
ADDED
|
@@ -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
|
-
|
|
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"))
|
data/lib/jobcompat/engine.rb
CHANGED
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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],
|
|
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
|
-
|
|
299
|
-
|
|
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
|
data/lib/jobcompat/formatter.rb
CHANGED
|
@@ -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 << ""
|
data/lib/jobcompat/version.rb
CHANGED
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.
|
|
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
|