featureparity 0.0.16 → 0.0.18
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/lib/fp/cli.rb +3 -0
- data/lib/fp/client.rb +87 -18
- data/lib/fp/commands/analyze.rb +213 -0
- data/lib/fp/commands/attach.rb +100 -136
- data/lib/fp/commands/attachments.rb +56 -83
- data/lib/fp/commands/changed_tests.rb +126 -0
- data/lib/fp/commands/ci_report.rb +133 -28
- data/lib/fp/commands/comment.rb +99 -0
- data/lib/fp/commands/help.rb +65 -4
- data/lib/fp/commands/init.rb +52 -12
- data/lib/fp/commands/propose_change.rb +46 -1
- data/lib/fp/commands/report.rb +64 -7
- data/lib/fp/commands.rb +3 -0
- data/lib/fp/junit.rb +50 -5
- data/lib/fp/source_fingerprint.rb +193 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +63 -0
- data/skills/fp/references/cli.md +92 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c4c40ac26de064785c94120334d42b1e5e906803db09ad49e9954be91aa85ed3
|
|
4
|
+
data.tar.gz: a83874973a35ed0335c58f9fe49b71fb66f4a5ba97e028a806c34929f4e5718f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 75f833ce8cbf39c68a0ba50c12bee2f369d3d374ab3d8f2ce3468af433e18af6190b0d17a57a1e102fb1c58da80c8a0dbfea5d0f96bab78f0101f4f47c607aeb
|
|
7
|
+
data.tar.gz: 30c2e791fdefc1be0b83b13b4823b520d457dcd9c44adcd79b3f403525c338fc25dd73486f4b4388a7a43de641b7b81244116cdced8db5101330bc8b983b4888
|
data/lib/fp/cli.rb
CHANGED
|
@@ -11,10 +11,13 @@ module Fp
|
|
|
11
11
|
'show' => Commands::Show,
|
|
12
12
|
'propose' => Commands::Propose,
|
|
13
13
|
'propose-change' => Commands::ProposeChange,
|
|
14
|
+
'comment' => Commands::Comment,
|
|
14
15
|
'history' => Commands::History,
|
|
16
|
+
'analyze' => Commands::Analyze,
|
|
15
17
|
'changes' => Commands::Changes,
|
|
16
18
|
'report' => Commands::Report,
|
|
17
19
|
'ci-report' => Commands::CiReport,
|
|
20
|
+
'changed-tests' => Commands::ChangedTests,
|
|
18
21
|
'check' => Commands::Check,
|
|
19
22
|
'matrix' => Commands::Matrix,
|
|
20
23
|
'export' => Commands::Export,
|
data/lib/fp/client.rb
CHANGED
|
@@ -106,6 +106,21 @@ module Fp
|
|
|
106
106
|
get("/api/projects/#{project_id}/requirement_changes", params)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
|
+
# GET /requirements/:id/analysis — the cached analysis, free to read. `analysis` is
|
|
110
|
+
# null when none exists or the wording changed since it ran (`stale` says which).
|
|
111
|
+
def get_requirement_analysis(requirement_id)
|
|
112
|
+
get("/api/requirements/#{requirement_id}/analysis")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# POST /requirements/:id/analyze — run (or return the still-current cached) analysis.
|
|
116
|
+
# One model call per requirement, which is what keeps it inside the request timeout.
|
|
117
|
+
def analyze_requirement(requirement_id, force: false, include_conflicts: false)
|
|
118
|
+
body = {}
|
|
119
|
+
body[:force] = true if force
|
|
120
|
+
body[:include_conflicts] = true if include_conflicts
|
|
121
|
+
post("/api/requirements/#{requirement_id}/analyze", body)
|
|
122
|
+
end
|
|
123
|
+
|
|
109
124
|
# GET /requirements/:id/changes
|
|
110
125
|
#
|
|
111
126
|
# A requirement's change history: what it was, what changed, who changed it, and
|
|
@@ -130,6 +145,15 @@ module Fp
|
|
|
130
145
|
put("/api/requirements/#{requirement_id}/changes/#{change_id}", params)
|
|
131
146
|
end
|
|
132
147
|
|
|
148
|
+
# POST /requirements/:id/changes/:change_id/comment
|
|
149
|
+
#
|
|
150
|
+
# Thread a comment onto the open proposal. The single-open-proposal rule means a
|
|
151
|
+
# second agent can't open a rival change; this lets it raise its concern *on* the
|
|
152
|
+
# proposal a human is about to review instead of hitting a dead-end 409.
|
|
153
|
+
def comment_requirement_change(requirement_id, change_id, params)
|
|
154
|
+
post("/api/requirements/#{requirement_id}/changes/#{change_id}/comment", params)
|
|
155
|
+
end
|
|
156
|
+
|
|
133
157
|
# GET /projects/:project_id/evidence — the receipts already filed for a project,
|
|
134
158
|
# optionally narrowed to one requirement slug and/or surface.
|
|
135
159
|
def list_evidence(project_id, slug: nil, surface: nil)
|
|
@@ -160,7 +184,7 @@ module Fp
|
|
|
160
184
|
# only do that by fetching the test files from GitHub, which fails silently on a
|
|
161
185
|
# private repo, so resolved results are always sent when we have them.
|
|
162
186
|
def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil, results: nil,
|
|
163
|
-
unmarked_failures: nil)
|
|
187
|
+
test_revisions: nil, unmarked_failures: nil)
|
|
164
188
|
body = {
|
|
165
189
|
ci_url: ci_url,
|
|
166
190
|
surface: surface,
|
|
@@ -169,6 +193,9 @@ module Fp
|
|
|
169
193
|
# Only send pr_url when set, so a merged-branch run stays a plain ingest.
|
|
170
194
|
body[:pr_url] = pr_url if pr_url && !pr_url.empty?
|
|
171
195
|
body[:results] = results if results && !results.empty?
|
|
196
|
+
# Testcase-level source receipts stay separate from aggregate Evidence outcomes:
|
|
197
|
+
# several tests may back one cell, and each can change independently.
|
|
198
|
+
body[:test_revisions] = test_revisions if test_revisions && !test_revisions.empty?
|
|
172
199
|
# Loose ends: failing tests with no fp:<slug> marker. The server stores them so the
|
|
173
200
|
# matrix can surface a red suite whose failures aren't bound to any requirement.
|
|
174
201
|
body[:unmarked_failures] = unmarked_failures if unmarked_failures && !unmarked_failures.empty?
|
|
@@ -176,6 +203,27 @@ module Fp
|
|
|
176
203
|
post("/api/projects/#{project_id}/ci_results", body)
|
|
177
204
|
end
|
|
178
205
|
|
|
206
|
+
# GET /projects/:project_id/changed_test_findings — every unresolved audit finding.
|
|
207
|
+
def list_changed_test_findings(project_id, surface: nil, test_type: nil)
|
|
208
|
+
params = {}
|
|
209
|
+
params[:surface] = surface if surface
|
|
210
|
+
params[:test_type] = test_type if test_type
|
|
211
|
+
get("/api/projects/#{project_id}/changed_test_findings", params)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# POST an assessment for the exact candidate fingerprint the agent inspected.
|
|
215
|
+
def assess_changed_test_finding(project_id, finding_id, classification:, explanation:,
|
|
216
|
+
candidate_fingerprint:)
|
|
217
|
+
post(
|
|
218
|
+
"/api/projects/#{project_id}/changed_test_findings/#{finding_id}/assessment",
|
|
219
|
+
{
|
|
220
|
+
classification: classification,
|
|
221
|
+
explanation: explanation,
|
|
222
|
+
candidate_fingerprint: candidate_fingerprint
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
179
227
|
# GET /projects/:project_id/parity — the computed parity grid and gate verdict.
|
|
180
228
|
#
|
|
181
229
|
# This replaces the old get_matrix/get_gaps stubs, which pointed at /api/matrix
|
|
@@ -205,7 +253,10 @@ module Fp
|
|
|
205
253
|
# ingest_ci_results). Sending them is what lets a surface whose repo isn't registered
|
|
206
254
|
# — or a private one the server can't read — be previewed at all.
|
|
207
255
|
def preview_parity(project_id, surface:, junit_xmls:, level: nil, results: nil)
|
|
208
|
-
|
|
256
|
+
# Older API deployments read `surface_filter` for report scoping while `surface`
|
|
257
|
+
# identifies the run's overlay. Send both during the rollout; current servers use
|
|
258
|
+
# `surface` for both meanings.
|
|
259
|
+
body = { surface: surface, surface_filter: surface, junit_xmls: Array(junit_xmls) }
|
|
209
260
|
body[:results] = results if results && !results.empty?
|
|
210
261
|
body[:level] = level if level
|
|
211
262
|
post("/api/projects/#{project_id}/parity/preview", body)
|
|
@@ -238,15 +289,28 @@ module Fp
|
|
|
238
289
|
end
|
|
239
290
|
|
|
240
291
|
# POST /requirements/:requirement_id/attachments
|
|
241
|
-
# Confirm an upload and create the attachment record.
|
|
242
|
-
|
|
243
|
-
|
|
292
|
+
# Confirm an upload and create the attachment record. Visual evidence may also
|
|
293
|
+
# carry the surface, capture time, observed deployment, and originating test run.
|
|
294
|
+
# evidence_key is a direct join to the evidence receipt (surface:repo:test_type).
|
|
295
|
+
def create_attachment(requirement_id, attachment_id:, s3_key:, filename:, content_type:, size_bytes:,
|
|
296
|
+
surface: nil, captured_at: nil, deployed_at: nil, deployment_url: nil,
|
|
297
|
+
test_run_url: nil, sha: nil, example_title: nil, evidence_key: nil)
|
|
298
|
+
body = {
|
|
244
299
|
attachment_id: attachment_id,
|
|
245
300
|
s3_key: s3_key,
|
|
246
301
|
filename: filename,
|
|
247
302
|
content_type: content_type,
|
|
248
303
|
size_bytes: size_bytes
|
|
249
|
-
}
|
|
304
|
+
}
|
|
305
|
+
body[:surface] = surface if surface
|
|
306
|
+
body[:captured_at] = captured_at if captured_at
|
|
307
|
+
body[:deployed_at] = deployed_at if deployed_at
|
|
308
|
+
body[:deployment_url] = deployment_url if deployment_url
|
|
309
|
+
body[:test_run_url] = test_run_url if test_run_url
|
|
310
|
+
body[:sha] = sha if sha
|
|
311
|
+
body[:example_title] = example_title if example_title
|
|
312
|
+
body[:evidence_key] = evidence_key if evidence_key
|
|
313
|
+
post("/api/requirements/#{requirement_id}/attachments", body)
|
|
250
314
|
end
|
|
251
315
|
|
|
252
316
|
# GET /requirements/:requirement_id/attachments/:id
|
|
@@ -261,23 +325,24 @@ module Fp
|
|
|
261
325
|
delete("/api/requirements/#{requirement_id}/attachments/#{attachment_id}")
|
|
262
326
|
end
|
|
263
327
|
|
|
264
|
-
# Upload
|
|
265
|
-
#
|
|
328
|
+
# Upload to S3 using a streaming request. Video evidence can be up to 100 MB;
|
|
329
|
+
# reading it all into the CLI process would needlessly double memory usage.
|
|
266
330
|
def upload_to_presigned_url(url, file_path, content_type)
|
|
267
331
|
uri = URI.parse(url)
|
|
268
|
-
|
|
332
|
+
file_size = File.size(file_path)
|
|
269
333
|
|
|
270
334
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
271
335
|
http.use_ssl = uri.scheme == 'https'
|
|
272
336
|
http.open_timeout = 30
|
|
273
|
-
http.read_timeout = 120
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
337
|
+
http.read_timeout = 120
|
|
338
|
+
|
|
339
|
+
response = File.open(file_path, 'rb') do |file|
|
|
340
|
+
request = Net::HTTP::Put.new(uri)
|
|
341
|
+
request.body_stream = file
|
|
342
|
+
request['Content-Type'] = content_type
|
|
343
|
+
request['Content-Length'] = file_size.to_s
|
|
344
|
+
http.request(request)
|
|
345
|
+
end
|
|
281
346
|
status = response.code.to_i
|
|
282
347
|
|
|
283
348
|
if status >= 200 && status < 300
|
|
@@ -360,7 +425,11 @@ module Fp
|
|
|
360
425
|
else
|
|
361
426
|
body.to_s.empty? ? "HTTP #{status}" : body.to_s[0..200]
|
|
362
427
|
end
|
|
363
|
-
{ ok: false, error: error_message, status: status }
|
|
428
|
+
result = { ok: false, error: error_message, status: status }
|
|
429
|
+
# Carry structured error details (e.g. the open proposal behind a 409) so a
|
|
430
|
+
# command can act on them instead of only printing the message.
|
|
431
|
+
result[:details] = data['details'] if data.is_a?(Hash) && data.key?('details')
|
|
432
|
+
result
|
|
364
433
|
end
|
|
365
434
|
end
|
|
366
435
|
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# `fp analyze [<slug>]` — a read-only triage sweep of requirement quality.
|
|
6
|
+
#
|
|
7
|
+
# Analysis is one model call per requirement, so this walks the catalog client-side,
|
|
8
|
+
# one request at a time, rather than asking the server to do it all in a single
|
|
9
|
+
# request (which cannot finish inside the API timeout on a real catalog). Results are
|
|
10
|
+
# cached server-side, so a requirement whose wording hasn't changed is read back for
|
|
11
|
+
# free and only new or edited ones cost a call — re-running the sweep is cheap.
|
|
12
|
+
#
|
|
13
|
+
# It never files anything. Proposing a change demotes the requirement to draft and
|
|
14
|
+
# marks its evidence suspect, so sweeping a catalog and proposing on every flag could
|
|
15
|
+
# put dozens of requirements up for review at once. The report says what *kind* of
|
|
16
|
+
# follow-up each finding suggests; a human picks which proposals get filed.
|
|
17
|
+
class Analyze < Base
|
|
18
|
+
KNOWN_FLAGS = {
|
|
19
|
+
project: :string,
|
|
20
|
+
status: :string,
|
|
21
|
+
conflicts: :boolean,
|
|
22
|
+
force: :boolean,
|
|
23
|
+
cached: :boolean
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
# Server-side findings that mean "this probably shouldn't be its own row".
|
|
27
|
+
REMOVAL_CHECKS = %w[is_focused_promise].freeze
|
|
28
|
+
|
|
29
|
+
ACTIONS = {
|
|
30
|
+
'conflict' => 'Stop and ask a human — two promises may contradict',
|
|
31
|
+
'propose_removal' => 'Consider `fp propose-change <slug> --removal` (duplicate, or a fragment of a broader promise)',
|
|
32
|
+
'needs_human' => 'Not clearly a product promise — a human decides whether it belongs',
|
|
33
|
+
'propose_change' => 'Consider `fp propose-change <slug>` to fix the wording',
|
|
34
|
+
'in_review' => 'A change is already awaiting review — skipped',
|
|
35
|
+
'not_analyzed' => 'No current analysis (run without --cached to analyze)',
|
|
36
|
+
'error' => 'Analysis failed',
|
|
37
|
+
'ok' => 'Nothing to act on'
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
ORDER = %w[conflict propose_removal needs_human propose_change in_review not_analyzed error ok].freeze
|
|
41
|
+
|
|
42
|
+
def run(args)
|
|
43
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
44
|
+
require_project!(opts)
|
|
45
|
+
|
|
46
|
+
project_id = resolve_project_id(opts[:project])
|
|
47
|
+
requirements = select_requirements(project_id, positional.first, opts)
|
|
48
|
+
|
|
49
|
+
entries = requirements.map { |r| analyze_one(r, opts, requirements.length > 1) }
|
|
50
|
+
report = build_report(entries)
|
|
51
|
+
|
|
52
|
+
output.success(report) { print_report(report) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def select_requirements(project_id, slug, opts)
|
|
58
|
+
result = client.list_requirements(project_id: project_id)
|
|
59
|
+
unless result[:ok]
|
|
60
|
+
output.error(result[:error], status: result[:status])
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
all = result[:data]['requirements'] || []
|
|
65
|
+
if slug
|
|
66
|
+
found = all.select { |r| r['slug'] == slug }
|
|
67
|
+
if found.empty?
|
|
68
|
+
output.error("Requirement '#{slug}' not found in project")
|
|
69
|
+
exit 1
|
|
70
|
+
end
|
|
71
|
+
return found
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
statuses = (opts[:status] || 'active,draft').split(',').map(&:strip)
|
|
75
|
+
all.select { |r| statuses.include?(r['status']) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def analyze_one(requirement, opts, sweeping)
|
|
79
|
+
slug = requirement['slug']
|
|
80
|
+
if requirement['pending_change_kind'].to_s != ''
|
|
81
|
+
return entry(requirement, 'in_review', nil)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
progress("#{slug} ...") if sweeping
|
|
85
|
+
analysis = cached_analysis(requirement)
|
|
86
|
+
|
|
87
|
+
if analysis.nil? || opts[:force] || opts[:conflicts] && analysis['conflicts'].nil?
|
|
88
|
+
return entry(requirement, 'not_analyzed', nil) if opts[:cached]
|
|
89
|
+
|
|
90
|
+
result = client.analyze_requirement(requirement['id'], force: opts[:force],
|
|
91
|
+
include_conflicts: opts[:conflicts])
|
|
92
|
+
unless result[:ok]
|
|
93
|
+
# Without access (or without the analyzer) every call would fail the same way.
|
|
94
|
+
if [401, 402, 403, 503].include?(result[:status])
|
|
95
|
+
output.error(result[:error], status: result[:status])
|
|
96
|
+
exit 1
|
|
97
|
+
end
|
|
98
|
+
return entry(requirement, 'error', nil, error: result[:error])
|
|
99
|
+
end
|
|
100
|
+
analysis = result[:data]['analysis']
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
entry(requirement, classify(analysis), analysis)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Free read of whatever is already cached and current; nil if there is none.
|
|
107
|
+
def cached_analysis(requirement)
|
|
108
|
+
result = client.get_requirement_analysis(requirement['id'])
|
|
109
|
+
result[:ok] ? result[:data]['analysis'] : nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Which kind of follow-up a finding suggests. Precedence matters: a possible
|
|
113
|
+
# contradiction outranks everything because it is a product decision, not an edit.
|
|
114
|
+
def classify(analysis)
|
|
115
|
+
return 'conflict' unless (analysis['conflicts'] || []).empty?
|
|
116
|
+
|
|
117
|
+
issues = analysis['issues'] || []
|
|
118
|
+
failed = failed_legitimacy_checks(analysis)
|
|
119
|
+
|
|
120
|
+
return 'propose_removal' if issues.any? { |i| i['type'] == 'duplicate' } ||
|
|
121
|
+
(failed & REMOVAL_CHECKS).any?
|
|
122
|
+
return 'needs_human' if analysis['verdict'] == 'questionable'
|
|
123
|
+
return 'propose_change' if analysis['verdict'] == 'needs_work' || !issues.empty?
|
|
124
|
+
|
|
125
|
+
'ok'
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def failed_legitimacy_checks(analysis)
|
|
129
|
+
checks = analysis.dig('legitimacy', 'checks') || {}
|
|
130
|
+
checks.reject { |_name, c| c['passes'] }.keys
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def entry(requirement, action, analysis, error: nil)
|
|
134
|
+
issues = (analysis && analysis['issues']) || []
|
|
135
|
+
{
|
|
136
|
+
'slug' => requirement['slug'],
|
|
137
|
+
'title' => requirement['title'],
|
|
138
|
+
'status' => requirement['status'],
|
|
139
|
+
'action' => action,
|
|
140
|
+
'verdict' => analysis && analysis['verdict'],
|
|
141
|
+
'failed_checks' => analysis ? failed_legitimacy_checks(analysis) : [],
|
|
142
|
+
'issues' => issues,
|
|
143
|
+
'duplicate_of' => issues.find { |i| i['type'] == 'duplicate' }&.dig('duplicate_of'),
|
|
144
|
+
'conflicts' => (analysis && analysis['conflicts']) || [],
|
|
145
|
+
'suggestions' => (analysis && analysis['suggestions']) || [],
|
|
146
|
+
'error' => error
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_report(entries)
|
|
151
|
+
counts = entries.group_by { |e| e['action'] }.transform_values(&:length)
|
|
152
|
+
{ 'analyzed' => entries.length, 'counts' => counts, 'requirements' => entries }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def print_report(report)
|
|
156
|
+
entries = report['requirements']
|
|
157
|
+
if entries.empty?
|
|
158
|
+
puts 'No requirements to analyze.'
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
if entries.length == 1
|
|
163
|
+
print_detail(entries.first)
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
puts "Analysis triage — #{entries.length} requirement(s). Nothing was proposed or changed."
|
|
168
|
+
puts
|
|
169
|
+
|
|
170
|
+
ORDER.each do |action|
|
|
171
|
+
group = entries.select { |e| e['action'] == action }
|
|
172
|
+
next if group.empty?
|
|
173
|
+
|
|
174
|
+
puts "#{action.tr('_', ' ').upcase} (#{group.length}) — #{ACTIONS[action]}"
|
|
175
|
+
next if action == 'ok'
|
|
176
|
+
|
|
177
|
+
group.each { |e| print_line(e) }
|
|
178
|
+
puts
|
|
179
|
+
end
|
|
180
|
+
puts
|
|
181
|
+
puts 'Report first; file only the proposals a human picks. Each proposal moves its requirement to draft.'
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def print_line(entry)
|
|
185
|
+
puts " #{entry['slug']} — #{truncate(entry['title'].to_s, 70)}"
|
|
186
|
+
puts " superseded by: #{entry['duplicate_of']}" if entry['duplicate_of']
|
|
187
|
+
entry['conflicts'].each do |c|
|
|
188
|
+
puts " conflicts with #{c['slug']} (#{(c['probability'].to_f * 100).round}%)"
|
|
189
|
+
end
|
|
190
|
+
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
191
|
+
puts " issues: #{entry['issues'].map { |i| i['type'] }.join(', ')}" unless entry['issues'].empty?
|
|
192
|
+
puts " error: #{entry['error']}" if entry['error']
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def print_detail(entry)
|
|
196
|
+
puts "#{entry['slug']} — #{entry['title']}"
|
|
197
|
+
puts " verdict: #{entry['verdict'] || '(none)'} suggested: #{ACTIONS[entry['action']]}"
|
|
198
|
+
puts " superseded by: #{entry['duplicate_of']}" if entry['duplicate_of']
|
|
199
|
+
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
200
|
+
entry['issues'].each { |i| puts " issue: #{i['type']}" }
|
|
201
|
+
entry['conflicts'].each do |c|
|
|
202
|
+
puts " conflict with #{c['slug']} (#{(c['probability'].to_f * 100).round}%): #{c['reason']}"
|
|
203
|
+
end
|
|
204
|
+
entry['suggestions'].each { |s| puts " - #{s}" }
|
|
205
|
+
puts " error: #{entry['error']}" if entry['error']
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def progress(message)
|
|
209
|
+
warn message unless output.json?
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|