featureparity 0.0.6 → 0.0.7

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: 59cbbdb1a7c4c7af5ab1d7016d22c7fdf670855c9e75a5c9d7350752c885b625
4
- data.tar.gz: 25d42454129c6d27c222e5d512578991b13a2abda65af67f8f39139550d24e96
3
+ metadata.gz: d668266132dc93de82fc60d7603f340ab05a8e5852fa4cde36ce9e6e4586ff18
4
+ data.tar.gz: 7d1b2691eeade5eabf110c518dca40808ce1beee2a0809b422376eaf914692c4
5
5
  SHA512:
6
- metadata.gz: 639bc26fbac4a013d0cef2051c438f1019ce915b5ce90537fe6cea0339f443f27234ae85062ba22f93cd989e7c6982390fb3be3068cd63bf658e081795243fca
7
- data.tar.gz: 0db47c4854e39bd6410c44839c26338d79833d129e6f68d3de26e83afff12a45b7199348fee0f1528a35697caace3e8d8084ee31438ec58474d175dcaf9a040c
6
+ metadata.gz: dafb288e14865ffd01b3cf739a510b8c7c2cf103b5e0867b07ce8f79264a14e8ab34ef95a01a73a77c3566adc2ce792488a3581f8ca184f1cd9b0678f8a4bce2
7
+ data.tar.gz: eee75e4d691fbd6884b0d417a1e8a9ae8c88e15da95ad1919b64f9a7e6a1ed915a5a8baa2687603cbe9d41a3f27738d4b0ae360d3ec97efb0885c45e62b4bcaf
data/lib/fp/cli.rb CHANGED
@@ -4,14 +4,19 @@ module Fp
4
4
  # Main CLI entry point - parses global flags and dispatches to commands
5
5
  class CLI
6
6
  COMMANDS = {
7
+ 'init' => Commands::Init,
7
8
  'projects' => Commands::Projects,
8
9
  'surfaces' => Commands::Surfaces,
9
10
  'list' => Commands::List,
10
11
  'show' => Commands::Show,
11
12
  'propose' => Commands::Propose,
13
+ 'propose-change' => Commands::ProposeChange,
14
+ 'history' => Commands::History,
12
15
  'report' => Commands::Report,
13
16
  'ci-report' => Commands::CiReport,
17
+ 'check' => Commands::Check,
14
18
  'matrix' => Commands::Matrix,
19
+ 'export' => Commands::Export,
15
20
  'profile' => Commands::Profile,
16
21
  'config' => Commands::ConfigCmd,
17
22
  'repos' => Commands::Repos,
@@ -36,8 +41,9 @@ module Fp
36
41
  exit 1
37
42
  end
38
43
 
39
- # Profile/config commands don't need auth
40
- needs_auth = !%w[profile config repos setup whoami version help].include?(command_name)
44
+ # Profile/config commands don't need auth. `init` is here too: it only writes
45
+ # files into the repo, and onboarding a repo must work before anyone has a key.
46
+ needs_auth = !%w[init profile config repos setup whoami version help].include?(command_name)
41
47
 
42
48
  if needs_auth
43
49
  config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
data/lib/fp/client.rb CHANGED
@@ -72,6 +72,30 @@ module Fp
72
72
  put("/api/requirements/#{id}", params)
73
73
  end
74
74
 
75
+ # GET /requirements/:id/changes
76
+ #
77
+ # A requirement's change history: what it was, what changed, who changed it, and
78
+ # what prompted the change. Includes the open proposal (if any) broken out.
79
+ def list_requirement_changes(requirement_id)
80
+ get("/api/requirements/#{requirement_id}/changes")
81
+ end
82
+
83
+ # POST /requirements/:id/changes
84
+ #
85
+ # Propose a modification, or a deletion when params[:removal] is true. The
86
+ # requirement drops back to draft until a human reviews it.
87
+ def propose_requirement_change(requirement_id, params)
88
+ post("/api/requirements/#{requirement_id}/changes", params)
89
+ end
90
+
91
+ # PUT /requirements/:id/changes/:change_id
92
+ #
93
+ # Approve or reject an open proposal. Humans only — an agent key gets a 403, which
94
+ # is deliberate and printed verbatim.
95
+ def review_requirement_change(requirement_id, change_id, params)
96
+ put("/api/requirements/#{requirement_id}/changes/#{change_id}", params)
97
+ end
98
+
75
99
  # GET /projects/:project_id/evidence — the receipts already filed for a project,
76
100
  # optionally narrowed to one requirement slug and/or surface.
77
101
  def list_evidence(project_id, slug: nil, surface: nil)
@@ -102,17 +126,34 @@ module Fp
102
126
  })
103
127
  end
104
128
 
105
- # GET /matrix (when API is ready)
106
- def get_matrix(project_id, format: :json)
107
- path = format == :csv ? '/api/matrix.csv' : '/api/matrix'
108
- get(path, { project_id: project_id })
129
+ # GET /projects/:project_id/parity the computed parity grid and gate verdict.
130
+ #
131
+ # This replaces the old get_matrix/get_gaps stubs, which pointed at /api/matrix
132
+ # and /api/gaps routes that were never built. `fp matrix` and `fp list --gaps`
133
+ # never called them, and instead each guessed at coverage client-side from
134
+ # required_surfaces without ever reading Evidence. The server now does the
135
+ # cross-referencing so the matrix, the gaps list, and the CI gate agree.
136
+ #
137
+ # @param level [String, nil] enforcement level override; nil uses the project's own
138
+ # @param surface [String, nil] restrict the report to a single surface key
139
+ def get_parity(project_id, level: nil, surface: nil)
140
+ params = {}
141
+ params[:level] = level if level
142
+ params[:surface] = surface if surface
143
+ get("/api/projects/#{project_id}/parity", params)
109
144
  end
110
145
 
111
- # GET /gaps (when API is ready)
112
- def get_gaps(project_id, surface: nil)
113
- params = { project_id: project_id }
114
- params[:surface] = surface if surface
115
- get('/api/gaps', params)
146
+ # POST /projects/:project_id/parity/preview the pull-request gate.
147
+ #
148
+ # Evaluates JUnit results as an overlay on the stored grid *without persisting them*,
149
+ # answering "would parity still hold if this merged?". GET parity can only describe
150
+ # what has already been recorded, which on a PR is the default branch's state; this
151
+ # is how a branch gets gated on its own results without writing evidence that would
152
+ # churn the live matrix.
153
+ def preview_parity(project_id, surface:, junit_xmls:, level: nil)
154
+ body = { surface: surface, junit_xmls: Array(junit_xmls) }
155
+ body[:level] = level if level
156
+ post("/api/projects/#{project_id}/parity/preview", body)
116
157
  end
117
158
 
118
159
  private
@@ -80,11 +80,60 @@ module Fp
80
80
  end
81
81
  end
82
82
 
83
+ # Resolve which project a command should act on, filling in opts[:project] from the
84
+ # repo-local .featureparity.yml when --project wasn't given.
85
+ #
86
+ # That fallback is what makes enforcement workable in CI and for agents: a
87
+ # generated workflow, and an agent in a fresh clone, shouldn't have to know (or
88
+ # duplicate) the project slug when the repo already declares it.
89
+ #
90
+ # Mutates opts rather than returning a separate value so the existing
91
+ # resolve_project_id / display code in each command needs no changes.
92
+ # Returns the slug; exits 1 with guidance when neither source has one.
93
+ def require_project!(opts, project_config = nil)
94
+ opts[:project] ||= (project_config || ProjectConfig.discover).project
95
+
96
+ unless opts[:project]
97
+ output.error(
98
+ '--project is required (or add `project: <slug>` to .featureparity.yml — see `fp init`)'
99
+ )
100
+ exit 1
101
+ end
102
+
103
+ opts[:project]
104
+ end
105
+
83
106
  def truncate(str, max)
84
107
  return str if str.nil? || str.length <= max
85
108
 
86
109
  str[0...max - 3] + '...'
87
110
  end
111
+
112
+ # Pull every occurrence of a repeatable flag out of args, returning
113
+ # [values, remaining_args].
114
+ #
115
+ # parse_flags keeps only the last occurrence of a repeated flag, so a flag that may
116
+ # legitimately appear more than once (--junit, for a suite split across shards) has
117
+ # to be extracted before the normal parse. Shared by `fp ci-report` and `fp check`.
118
+ def extract_repeated(args, flag)
119
+ values = []
120
+ rest = []
121
+ i = 0
122
+ while i < args.length
123
+ arg = args[i]
124
+ if arg == flag && i + 1 < args.length
125
+ values << args[i + 1]
126
+ i += 2
127
+ elsif arg.start_with?("#{flag}=")
128
+ values << arg.split('=', 2)[1]
129
+ i += 1
130
+ else
131
+ rest << arg
132
+ i += 1
133
+ end
134
+ end
135
+ [values, rest]
136
+ end
88
137
  end
89
138
  end
90
139
  end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp check [--project X] [--surface api] [--level covered] [--junit FILE] [--json]
6
+ #
7
+ # The enforcement gate. Fetches the parity report and exits non-zero when the
8
+ # project violates its enforcement level, which is what makes it usable as a
9
+ # required status check on a pull request.
10
+ #
11
+ # Two modes:
12
+ #
13
+ # fp check Gate on what has been recorded. Answers "is this
14
+ # project currently in violation?"
15
+ # fp check --junit r.xml Gate on *this run's* results, applied on top of the
16
+ # --surface api recorded grid but never saved. Answers "would parity
17
+ # still hold if this branch merged?"
18
+ #
19
+ # The second is the pull-request gate. Without it a PR run can only see the default
20
+ # branch's state, so it can't tell you that the branch in front of it deleted the only
21
+ # test covering a requirement. Reporting the branch's evidence for real would answer
22
+ # that too, but would let every in-progress branch churn the live matrix — so the
23
+ # results are evaluated and thrown away.
24
+ #
25
+ # Everything about *what counts as a violation* lives server-side in ParityReport —
26
+ # this command renders the verdict and turns it into an exit code. That split is
27
+ # deliberate: if the policy lived in the CLI, every repo would enforce whichever
28
+ # version of the gem its CI happened to install.
29
+ #
30
+ # Exit codes are the contract CI reads:
31
+ # 0 gate passed
32
+ # 1 gate failed (violations), or the request failed
33
+ #
34
+ # `--warn-only` reports violations and still exits 0, for landing the gate on a repo
35
+ # that isn't clean yet — you get the annotations without blocking every merge on day
36
+ # one. Prefer setting the project's level to `regression` over living on --warn-only
37
+ # forever, since a warn-only gate is one nobody reads.
38
+ class Check < Base
39
+ KNOWN_FLAGS = {
40
+ project: :string,
41
+ surface: :string,
42
+ level: :string,
43
+ junit: :string,
44
+ warn_only: :boolean
45
+ }.freeze
46
+
47
+ def run(args)
48
+ # Collect every --junit occurrence (parse_flags keeps only the last of a repeated
49
+ # flag), matching `fp ci-report`.
50
+ junit_paths, rest = extract_repeated(args, '--junit')
51
+ opts, = parse_flags(rest, KNOWN_FLAGS)
52
+
53
+ project_config = ProjectConfig.discover
54
+ project_slug = require_project!(opts, project_config)
55
+
56
+ surface = opts[:surface] || project_config.surface
57
+ level = opts[:level] || project_config.enforcement
58
+
59
+ project_id = resolve_project_id(project_slug)
60
+
61
+ result =
62
+ if junit_paths.empty?
63
+ client.get_parity(project_id, level: level, surface: surface)
64
+ else
65
+ preview(project_id, junit_paths, surface, level)
66
+ end
67
+
68
+ unless result[:ok]
69
+ output.error(result[:error], status: result[:status])
70
+ exit 1
71
+ end
72
+
73
+ report = result[:data]['parity'] || {}
74
+ violations = report['violations'] || []
75
+ passed = report['passing']
76
+
77
+ render(report, violations, passed, project_slug, opts)
78
+
79
+ # A failing gate is the whole point of the command, so it must be the exit code
80
+ # and not just something printed. --warn-only downgrades it to advisory.
81
+ exit 1 if !passed && !opts[:warn_only]
82
+ end
83
+
84
+ private
85
+
86
+ # Preview mode needs to know which surface the results belong to, because the
87
+ # server uses it to decide which cells the run had a chance to cover — and
88
+ # therefore which vanished tests it may legitimately flag.
89
+ def preview(project_id, junit_paths, surface, level)
90
+ unless surface
91
+ output.error(
92
+ '--surface is required with --junit (the surface these results are for). ' \
93
+ 'Set it via --surface or `surface:` in .featureparity.yml.'
94
+ )
95
+ exit 1
96
+ end
97
+
98
+ missing = junit_paths.reject { |p| File.file?(p) }
99
+ unless missing.empty?
100
+ output.error("JUnit file(s) not found: #{missing.join(', ')}")
101
+ exit 1
102
+ end
103
+
104
+ client.preview_parity(
105
+ project_id,
106
+ surface: surface,
107
+ junit_xmls: junit_paths.map { |p| File.read(p) },
108
+ level: level
109
+ )
110
+ end
111
+
112
+ def render(report, violations, passed, project_slug, opts)
113
+ payload = report.merge('project' => project_slug, 'warn_only' => !!opts[:warn_only])
114
+
115
+ output.success(payload) do
116
+ summary = report['summary'] || {}
117
+ scope = report['surface'] ? " (surface: #{report['surface']})" : ''
118
+ puts "FeatureParity gate: #{project_slug}#{scope}"
119
+ puts "Enforcement level: #{report['level']}"
120
+ if report['preview']
121
+ reported = Array(report['preview_surfaces']).join(', ')
122
+ puts "Mode: preview — evaluating this run's results#{reported.empty? ? '' : " for #{reported}"} (nothing saved)"
123
+ end
124
+ puts
125
+
126
+ if (report['surfaces'] || []).empty?
127
+ puts 'This project has no surfaces configured, so there is nothing to enforce.'
128
+ puts "Add one with: fp surfaces add <key> --project #{project_slug}"
129
+ next
130
+ end
131
+
132
+ if summary['requirements'].to_i.zero?
133
+ puts 'No requirements to measure. Nothing to enforce yet.'
134
+ next
135
+ end
136
+
137
+ print_state_counts(summary)
138
+ print_violations(violations)
139
+ print_pending_reviews(report, project_slug)
140
+ print_unscoped_warning(summary, project_slug)
141
+ print_verdict(passed, violations, opts)
142
+ end
143
+ end
144
+
145
+ def print_state_counts(summary)
146
+ counts = Parity.count_labels(summary)
147
+
148
+ puts "#{summary['requirements']} requirement(s) measured, #{summary['cells']} required surface(s)"
149
+ puts " #{counts.join(' ')}" unless counts.empty?
150
+ puts
151
+ end
152
+
153
+ # Requirements a proposed change has knocked back to draft. Informational, never a
154
+ # violation — a human's review latency is not something to fail an agent's build
155
+ # over. But they stay in the grid (proposing a change must not be a way out of the
156
+ # gate), and proposing marks their evidence `suspect`, so saying nothing here would
157
+ # leave a reader guessing why a row that was green went ⚠.
158
+ def print_pending_reviews(report, project_slug)
159
+ pending = report['pending_reviews'] || []
160
+ return if pending.empty?
161
+
162
+ puts "#{pending.size} requirement(s) awaiting review of a proposed change " \
163
+ "(#{Parity.pending_summary(pending)}):"
164
+ output.table(
165
+ %w[SLUG PROPOSED TITLE],
166
+ pending.map { |p| [p['slug'], p['kind'], truncate(p['title'].to_s, 48)] }
167
+ )
168
+ puts 'These still count toward the gate — proposing a change is not a way out of it.'
169
+ puts "Their evidence is suspect until the change is reviewed: fp history <slug>#{project_scope(project_slug)}"
170
+ puts
171
+ end
172
+
173
+ def print_violations(violations)
174
+ return if violations.empty?
175
+
176
+ puts "#{violations.size} violation(s):"
177
+ output.table(
178
+ %w[SLUG SURFACE STATE WHY],
179
+ violations.map do |v|
180
+ [v['slug'], v['surface'], "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
181
+ end
182
+ )
183
+ puts
184
+ end
185
+
186
+ # Informational, never a violation: an active requirement naming no surface this
187
+ # project has can't be satisfied, so it would pass any gate silently. Worth
188
+ # saying out loud without blocking a merge on someone else's data-entry gap.
189
+ # `--project` is only worth echoing in a suggested command when the slug didn't
190
+ # come from .featureparity.yml — otherwise it's noise the agent would copy.
191
+ def project_scope(project_slug)
192
+ ProjectConfig.discover.project ? '' : " --project #{project_slug}"
193
+ end
194
+
195
+ def print_unscoped_warning(summary, project_slug)
196
+ unscoped = summary['unscoped'].to_i
197
+ return unless unscoped.positive?
198
+
199
+ puts "Note: #{unscoped} requirement(s) name no surface this project has, so " \
200
+ 'nothing can satisfy them.'
201
+ puts " Review with: fp list --project #{project_slug}"
202
+ puts
203
+ end
204
+
205
+ def print_verdict(passed, violations, opts)
206
+ if passed
207
+ puts 'Gate passed.'
208
+ elsif opts[:warn_only]
209
+ puts "Gate FAILED with #{violations.size} violation(s) — exiting 0 because --warn-only was given."
210
+ else
211
+ puts "Gate FAILED with #{violations.size} violation(s)."
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -39,7 +39,7 @@ module Fp
39
39
  junit_paths, rest = extract_repeated(args, '--junit')
40
40
  opts, _positional = parse_flags(rest, KNOWN_FLAGS)
41
41
 
42
- require_flag(opts, :project)
42
+ require_project!(opts)
43
43
  require_flag(opts, :surface)
44
44
  require_flag(opts, :ci_url)
45
45
 
@@ -87,26 +87,6 @@ module Fp
87
87
  # Pull every occurrence of a repeatable flag (and its value) out of args,
88
88
  # returning [collected_values, remaining_args]. Leaves all other flags for
89
89
  # the normal parser.
90
- def extract_repeated(args, flag)
91
- values = []
92
- rest = []
93
- i = 0
94
- while i < args.length
95
- arg = args[i]
96
- if arg == flag && i + 1 < args.length
97
- values << args[i + 1]
98
- i += 2
99
- elsif arg.start_with?("#{flag}=")
100
- values << arg.split('=', 2)[1]
101
- i += 1
102
- else
103
- rest << arg
104
- i += 1
105
- end
106
- end
107
- [values, rest]
108
- end
109
-
110
90
  def emit_summary(opts, totals)
111
91
  data = {
112
92
  project: opts[:project],
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # Renders every requirement in a project as a single Markdown document —
6
+ # title, status, category, surfaces, why/acceptance criteria, and an
7
+ # evidence summary per surface — nested parent/child like `fp list`.
8
+ #
9
+ # Unlike `fp matrix --csv` (a parity spreadsheet) this is meant to be read
10
+ # top to bottom: handed to a stakeholder, dropped in a repo's docs/, or
11
+ # attached to a release as a point-in-time record of what the product is
12
+ # supposed to do.
13
+ class Export < Base
14
+ KNOWN_FLAGS = {
15
+ project: :string,
16
+ status: :string,
17
+ category: :string,
18
+ surface: :string,
19
+ output: :string
20
+ }.freeze
21
+
22
+ def run(args)
23
+ opts, = parse_flags(args, KNOWN_FLAGS)
24
+ require_project!(opts)
25
+
26
+ project_id = resolve_project_id(opts[:project])
27
+
28
+ result = client.list_requirements(project_id: project_id, category: opts[:category])
29
+ unless result[:ok]
30
+ output.error(result[:error], status: result[:status])
31
+ exit 1
32
+ end
33
+
34
+ requirements = result[:data]['requirements'] || []
35
+
36
+ status_filter = opts[:status]
37
+ requirements = requirements.select { |r| r['status'] == status_filter } if status_filter
38
+
39
+ if opts[:surface]
40
+ requirements = requirements.select { |r| (r['required_surfaces'] || []).include?(opts[:surface]) }
41
+ end
42
+
43
+ document = render_document(opts[:project], requirements, project_id)
44
+
45
+ if opts[:output]
46
+ File.write(opts[:output], document)
47
+ end
48
+
49
+ output.success({ project: opts[:project], requirement_count: requirements.length, document: document }) do
50
+ if opts[:output]
51
+ puts "Wrote #{requirements.length} requirement(s) to #{opts[:output]}"
52
+ else
53
+ puts document
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def render_document(project_slug, requirements, project_id)
61
+ lines = []
62
+ lines << "# Requirements: #{project_slug}"
63
+ lines << ''
64
+ lines << "_Generated #{Time.now.utc.strftime('%Y-%m-%d %H:%M UTC')} — #{requirements.length} requirement(s)._"
65
+ lines << ''
66
+
67
+ if requirements.empty?
68
+ lines << 'No requirements match the given filters.'
69
+ return lines.join("\n") + "\n"
70
+ end
71
+
72
+ parents = requirements.select { |r| r['parent_id'].nil? || r['parent_id'] == '' }
73
+ children_by_parent = requirements
74
+ .select { |r| r['parent_id'] && r['parent_id'] != '' }
75
+ .group_by { |r| r['parent_id'] }
76
+
77
+ parent_ids = parents.map { |p| p['id'] }.to_set
78
+ orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
79
+
80
+ evidence_by_slug = fetch_evidence_by_slug(project_id, requirements)
81
+
82
+ parents.each do |req|
83
+ render_requirement(lines, req, evidence_by_slug[req['slug']], level: 2)
84
+ (children_by_parent[req['id']] || []).each do |child|
85
+ render_requirement(lines, child, evidence_by_slug[child['slug']], level: 3)
86
+ end
87
+ end
88
+
89
+ orphans.each { |req| render_requirement(lines, req, evidence_by_slug[req['slug']], level: 2) }
90
+
91
+ lines.join("\n") + "\n"
92
+ end
93
+
94
+ def render_requirement(lines, req, evidence, level:)
95
+ heading = '#' * level
96
+ lines << "#{heading} #{req['title']}"
97
+ lines << ''
98
+ lines << "- **Slug:** `#{req['slug']}`"
99
+ lines << "- **Status:** #{req['status']}"
100
+ lines << "- **Category:** #{req['category'] || '(none)'}"
101
+
102
+ surfaces = req['required_surfaces'] || []
103
+ lines << "- **Required Surfaces:** #{surfaces.empty? ? '(none)' : surfaces.join(', ')}"
104
+ lines << ''
105
+
106
+ if req['why'] && !req['why'].empty?
107
+ lines << '**Why:**'
108
+ lines << ''
109
+ lines << req['why']
110
+ lines << ''
111
+ end
112
+
113
+ if req['acceptance'] && !req['acceptance'].empty?
114
+ lines << '**Acceptance Criteria:**'
115
+ lines << ''
116
+ lines << req['acceptance']
117
+ lines << ''
118
+ end
119
+
120
+ lines << '**Evidence:**'
121
+ lines << ''
122
+ if evidence.nil? || evidence.empty?
123
+ lines << '_(none reported)_'
124
+ else
125
+ evidence.group_by { |e| e['surface'] }.each do |surface, records|
126
+ latest = records.max_by { |e| e['reported_at'].to_s }
127
+ lines << "- `#{surface}`: #{latest['status']}#{latest['reported_at'] ? " (as of #{latest['reported_at']})" : ''}"
128
+ end
129
+ end
130
+ lines << ''
131
+ end
132
+
133
+ # Evidence is keyed per-slug on the API, so fetch each requirement's records
134
+ # individually. Fine for a document export (infrequent, human-facing) rather
135
+ # than a hot path like `fp matrix`.
136
+ def fetch_evidence_by_slug(project_id, requirements)
137
+ requirements.each_with_object({}) do |req, memo|
138
+ result = client.list_evidence(project_id, slug: req['slug'])
139
+ memo[req['slug']] = result[:ok] ? (result[:data]['evidence'] || []) : []
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end