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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd15c5892d88c0202433fd6e11993440f45cd3cb8c754c0c7138309008190cbb
4
- data.tar.gz: 356c8c54af360a47639e2dfc6313774247ff93534eb02b6666656f738d69ce70
3
+ metadata.gz: c4c40ac26de064785c94120334d42b1e5e906803db09ad49e9954be91aa85ed3
4
+ data.tar.gz: a83874973a35ed0335c58f9fe49b71fb66f4a5ba97e028a806c34929f4e5718f
5
5
  SHA512:
6
- metadata.gz: d0d96204c4cf14ff9c7f5bbf1930f1ad6f170b11be67651df1cda342c2ad41e53e1a09ea93bed84db620d2c867f26ffbc313fa2d132f7b5b8b474026b4ea9ee8
7
- data.tar.gz: c43e169274e9c6484b6e41c2bcb1c5748eaee9dabad8c39f523c7ed30a08f0edaeae3df7637edc799d2089165624108e72fdb517c95d580cc51c1b747064726b
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
- body = { surface: surface, junit_xmls: Array(junit_xmls) }
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
- def create_attachment(requirement_id, attachment_id:, s3_key:, filename:, content_type:, size_bytes:)
243
- post("/api/requirements/#{requirement_id}/attachments", {
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 a file to S3 using a presigned URL.
265
- # This is called after presign_attachment returns the upload_url.
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
- file_content = File.binread(file_path)
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 # Allow longer for file upload
274
-
275
- request = Net::HTTP::Put.new(uri)
276
- request.body = file_content
277
- request['Content-Type'] = content_type
278
- request['Content-Length'] = file_content.bytesize.to_s
279
-
280
- response = http.request(request)
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