featureparity 0.0.17 → 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: d20b326add84bbad1fc34a9b2b41c70216f7e9f2797f47cc9cba9f4293d27f1e
4
- data.tar.gz: ae9721b86841174ee64f1925b0878e5ca5b5e34ea1f467ea0275887526515695
3
+ metadata.gz: c4c40ac26de064785c94120334d42b1e5e906803db09ad49e9954be91aa85ed3
4
+ data.tar.gz: a83874973a35ed0335c58f9fe49b71fb66f4a5ba97e028a806c34929f4e5718f
5
5
  SHA512:
6
- metadata.gz: 87aa7b100433761979acec4c6a3f66c665757ebd77924cf1bab483861a1855be92acba93d3a6bd0e2f0cdc9daf9c887380a808eab96c467c3a41ee4e665f8bb0
7
- data.tar.gz: 640c69700985e08ce0c6aec534d42d544db0d04771ae9f9be254e24aca3952cbbff7f52456c1c7b506c8bbf5e6a3d3b54c8c7959d6575d159d19cff5cad69c0b
6
+ metadata.gz: 75f833ce8cbf39c68a0ba50c12bee2f369d3d374ab3d8f2ce3468af433e18af6190b0d17a57a1e102fb1c58da80c8a0dbfea5d0f96bab78f0101f4f47c607aeb
7
+ data.tar.gz: 30c2e791fdefc1be0b83b13b4823b520d457dcd9c44adcd79b3f403525c338fc25dd73486f4b4388a7a43de641b7b81244116cdced8db5101330bc8b983b4888
data/lib/fp/cli.rb CHANGED
@@ -13,6 +13,7 @@ module Fp
13
13
  'propose-change' => Commands::ProposeChange,
14
14
  'comment' => Commands::Comment,
15
15
  'history' => Commands::History,
16
+ 'analyze' => Commands::Analyze,
16
17
  'changes' => Commands::Changes,
17
18
  'report' => Commands::Report,
18
19
  'ci-report' => Commands::CiReport,
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
@@ -274,15 +289,28 @@ module Fp
274
289
  end
275
290
 
276
291
  # POST /requirements/:requirement_id/attachments
277
- # Confirm an upload and create the attachment record.
278
- def create_attachment(requirement_id, attachment_id:, s3_key:, filename:, content_type:, size_bytes:)
279
- 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 = {
280
299
  attachment_id: attachment_id,
281
300
  s3_key: s3_key,
282
301
  filename: filename,
283
302
  content_type: content_type,
284
303
  size_bytes: size_bytes
285
- })
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)
286
314
  end
287
315
 
288
316
  # GET /requirements/:requirement_id/attachments/:id
@@ -297,23 +325,24 @@ module Fp
297
325
  delete("/api/requirements/#{requirement_id}/attachments/#{attachment_id}")
298
326
  end
299
327
 
300
- # Upload a file to S3 using a presigned URL.
301
- # 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.
302
330
  def upload_to_presigned_url(url, file_path, content_type)
303
331
  uri = URI.parse(url)
304
- file_content = File.binread(file_path)
332
+ file_size = File.size(file_path)
305
333
 
306
334
  http = Net::HTTP.new(uri.host, uri.port)
307
335
  http.use_ssl = uri.scheme == 'https'
308
336
  http.open_timeout = 30
309
- http.read_timeout = 120 # Allow longer for file upload
310
-
311
- request = Net::HTTP::Put.new(uri)
312
- request.body = file_content
313
- request['Content-Type'] = content_type
314
- request['Content-Length'] = file_content.bytesize.to_s
315
-
316
- 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
317
346
  status = response.code.to_i
318
347
 
319
348
  if status >= 200 && status < 300
@@ -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
@@ -4,186 +4,150 @@ require_relative 'base'
4
4
 
5
5
  module Fp
6
6
  module Commands
7
- # Upload, list, and manage attachments on requirements.
8
- #
9
- # Usage:
10
- # fp attach <slug> <file> Upload a file to a requirement
11
- # fp attachments <slug> List attachments for a requirement
12
- # fp attachment <slug> <id> Get download URL for an attachment
13
- # fp attachment rm <slug> <id> Delete an attachment
7
+ # Upload visual test evidence or another supporting file to a requirement.
14
8
  class Attach < Base
15
- # Allowed content types and their file extensions
16
9
  CONTENT_TYPES = {
17
- 'png' => 'image/png',
18
- 'jpg' => 'image/jpeg',
19
- 'jpeg' => 'image/jpeg',
20
- 'gif' => 'image/gif',
21
- 'webp' => 'image/webp',
22
- 'svg' => 'image/svg+xml',
23
- 'pdf' => 'application/pdf',
24
- 'txt' => 'text/plain',
25
- 'md' => 'text/markdown'
10
+ 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
11
+ 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
12
+ 'mp4' => 'video/mp4', 'webm' => 'video/webm', 'mov' => 'video/quicktime',
13
+ 'pdf' => 'application/pdf', 'txt' => 'text/plain', 'md' => 'text/markdown',
14
+ 'json' => 'application/json'
15
+ }.freeze
16
+ VISUAL_EXTENSIONS = %w[png jpg jpeg gif webp mp4 webm mov].freeze
17
+ MAX_FILE_SIZE = 100 * 1024 * 1024
18
+ KNOWN_FLAGS = {
19
+ project: :string,
20
+ surface: :string,
21
+ captured_at: :string,
22
+ deployed_at: :string,
23
+ deployment_url: :string,
24
+ test_run_url: :string,
25
+ sha: :string,
26
+ example_title: :string,
27
+ evidence_key: :string
26
28
  }.freeze
27
-
28
- MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
29
29
 
30
30
  def run(args)
31
- case args[0]
32
- when 'rm', 'delete'
33
- delete_attachment(args[1..])
34
- else
35
- upload_attachment(args)
36
- end
31
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
32
+ return delete_attachment(opts, positional.drop(1)) if %w[rm delete].include?(positional.first)
33
+
34
+ upload_attachment(opts, positional)
37
35
  end
38
36
 
39
37
  private
40
38
 
41
- def upload_attachment(args)
42
- slug = args[0]
43
- file_path = args[1]
39
+ def upload_attachment(opts, positional)
40
+ slug, file_path = positional
41
+ return usage_error('Missing requirement slug') if slug.to_s.empty?
42
+ return usage_error('Missing file path') if file_path.to_s.empty?
43
+ return command_error("File not found: #{file_path}") unless File.file?(file_path)
44
44
 
45
- if slug.nil? || slug.empty?
46
- Output.error('Missing requirement slug. Usage: fp attach <slug> <file>')
47
- return 1
48
- end
49
-
50
- if file_path.nil? || file_path.empty?
51
- Output.error('Missing file path. Usage: fp attach <slug> <file>')
52
- return 1
53
- end
54
-
55
- unless File.exist?(file_path)
56
- Output.error("File not found: #{file_path}")
57
- return 1
58
- end
59
-
60
- project_id = require_project_id!
61
- return 1 unless project_id
62
-
63
- # Validate file
64
45
  filename = File.basename(file_path)
65
- ext = File.extname(filename).delete('.').downcase
66
- content_type = CONTENT_TYPES[ext]
67
-
46
+ extension = File.extname(filename).delete('.').downcase
47
+ content_type = CONTENT_TYPES[extension]
68
48
  unless content_type
69
- Output.error("Unsupported file type: .#{ext}")
70
- Output.info("Allowed: #{CONTENT_TYPES.keys.join(', ')}")
71
- return 1
49
+ return command_error("Unsupported file type: .#{extension}. Allowed: #{CONTENT_TYPES.keys.join(', ')}")
72
50
  end
73
51
 
74
- file_size = File.size(file_path)
75
- if file_size > MAX_FILE_SIZE
76
- Output.error("File too large: #{format_size(file_size)} (max #{format_size(MAX_FILE_SIZE)})")
77
- return 1
52
+ size = File.size(file_path)
53
+ return command_error("File is empty: #{file_path}") if size.zero?
54
+ if size > MAX_FILE_SIZE
55
+ return command_error("File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})")
78
56
  end
79
-
80
- # Resolve requirement
81
- result = client.find_requirement_by_slug(project_id, slug)
82
- unless result[:ok]
83
- Output.error(result[:error])
84
- return 1
57
+ if VISUAL_EXTENSIONS.include?(extension) && opts[:surface].to_s.empty?
58
+ return command_error('--surface is required for screenshot and video evidence')
85
59
  end
86
60
 
87
- requirement = result[:data]['requirement']
88
- requirement_id = requirement['id']
61
+ require_project!(opts)
62
+ project_id = resolve_project_id(opts[:project])
63
+ requirement_result = client.find_requirement_by_slug(project_id, slug)
64
+ return api_error(requirement_result) unless requirement_result[:ok]
89
65
 
90
- # Step 1: Get presigned URL
91
- Output.info("Requesting upload URL for #{filename}...")
66
+ requirement_id = requirement_result[:data].dig('requirement', 'id')
92
67
  presign_result = client.presign_attachment(
93
68
  requirement_id,
94
69
  filename: filename,
95
70
  content_type: content_type,
96
- size_bytes: file_size
71
+ size_bytes: size
97
72
  )
73
+ return api_error(presign_result, prefix: 'Failed to get upload URL') unless presign_result[:ok]
98
74
 
99
- unless presign_result[:ok]
100
- Output.error("Failed to get upload URL: #{presign_result[:error]}")
101
- return 1
102
- end
103
-
104
- presign_data = presign_result[:data]['presign']
105
- upload_url = presign_data['upload_url']
106
- attachment_id = presign_data['attachment_id']
107
- s3_key = presign_data['s3_key']
108
-
109
- # Step 2: Upload to S3
110
- Output.info("Uploading #{filename} (#{format_size(file_size)})...")
111
- upload_result = client.upload_to_presigned_url(upload_url, file_path, content_type)
112
-
113
- unless upload_result[:ok]
114
- Output.error("Upload failed: #{upload_result[:error]}")
115
- return 1
116
- end
75
+ presign = presign_result[:data]['presign']
76
+ upload_result = client.upload_to_presigned_url(presign['upload_url'], file_path, content_type)
77
+ return api_error(upload_result, prefix: 'Upload failed') unless upload_result[:ok]
117
78
 
118
- # Step 3: Confirm upload
119
- Output.info('Confirming upload...')
120
79
  create_result = client.create_attachment(
121
80
  requirement_id,
122
- attachment_id: attachment_id,
123
- s3_key: s3_key,
81
+ attachment_id: presign['attachment_id'],
82
+ s3_key: presign['s3_key'],
124
83
  filename: filename,
125
84
  content_type: content_type,
126
- size_bytes: file_size
85
+ size_bytes: size,
86
+ surface: opts[:surface],
87
+ captured_at: opts[:captured_at],
88
+ deployed_at: opts[:deployed_at],
89
+ deployment_url: opts[:deployment_url],
90
+ test_run_url: opts[:test_run_url],
91
+ sha: opts[:sha],
92
+ example_title: opts[:example_title],
93
+ evidence_key: opts[:evidence_key]
127
94
  )
128
-
129
- unless create_result[:ok]
130
- Output.error("Failed to confirm upload: #{create_result[:error]}")
131
- return 1
132
- end
95
+ return api_error(create_result, prefix: 'Failed to confirm upload') unless create_result[:ok]
133
96
 
134
97
  attachment = create_result[:data]['attachment']
135
- Output.success("Uploaded #{filename} to requirement '#{slug}'")
136
- Output.info(" ID: #{attachment['id']}")
98
+ output.success({ attachment: attachment }) do
99
+ puts "Uploaded #{filename} to requirement '#{slug}'."
100
+ puts " ID: #{attachment['id']}"
101
+ puts " Surface: #{attachment['surface']}" if attachment['surface']
102
+ puts " Test: #{attachment['example_title']}" if attachment['example_title']
103
+ puts " Evidence key: #{attachment['evidence_key']}" if attachment['evidence_key']
104
+ puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
105
+ puts " Deploy: #{attachment['deployment_url']}" if attachment['deployment_url']
106
+ puts ' Deployment context: unknown' if VISUAL_EXTENSIONS.include?(extension) && attachment['deployed_at'].to_s.empty?
107
+ end
137
108
  0
138
109
  end
139
110
 
140
- def delete_attachment(args)
141
- slug = args[0]
142
- attachment_id = args[1]
111
+ def delete_attachment(opts, positional)
112
+ slug, attachment_id = positional
113
+ return usage_error('Missing requirement slug', delete: true) if slug.to_s.empty?
114
+ return usage_error('Missing attachment ID', delete: true) if attachment_id.to_s.empty?
143
115
 
144
- if slug.nil? || slug.empty?
145
- Output.error('Missing requirement slug. Usage: fp attachment rm <slug> <id>')
146
- return 1
147
- end
116
+ require_project!(opts)
117
+ project_id = resolve_project_id(opts[:project])
118
+ requirement_result = client.find_requirement_by_slug(project_id, slug)
119
+ return api_error(requirement_result) unless requirement_result[:ok]
148
120
 
149
- if attachment_id.nil? || attachment_id.empty?
150
- Output.error('Missing attachment ID. Usage: fp attachment rm <slug> <id>')
151
- return 1
152
- end
153
-
154
- project_id = require_project_id!
155
- return 1 unless project_id
156
-
157
- # Resolve requirement
158
- result = client.find_requirement_by_slug(project_id, slug)
159
- unless result[:ok]
160
- Output.error(result[:error])
161
- return 1
162
- end
121
+ requirement_id = requirement_result[:data].dig('requirement', 'id')
122
+ result = client.delete_attachment(requirement_id, attachment_id)
123
+ return api_error(result, prefix: 'Failed to delete attachment') unless result[:ok]
163
124
 
164
- requirement = result[:data]['requirement']
165
- requirement_id = requirement['id']
125
+ output.success({ id: attachment_id, deleted: true }) { puts "Deleted attachment #{attachment_id}." }
126
+ 0
127
+ end
166
128
 
167
- # Delete attachment
168
- delete_result = client.delete_attachment(requirement_id, attachment_id)
129
+ def usage_error(message, delete: false)
130
+ usage = delete ? 'fp attach rm <slug> <id> [--project <project>]' :
131
+ 'fp attach <slug> <file> [--surface <surface>] [--project <project>]'
132
+ command_error("#{message}. Usage: #{usage}")
133
+ end
169
134
 
170
- unless delete_result[:ok]
171
- Output.error("Failed to delete attachment: #{delete_result[:error]}")
172
- return 1
173
- end
135
+ def api_error(result, prefix: nil)
136
+ message = [prefix, result[:error]].compact.join(': ')
137
+ output.error(message, status: result[:status])
138
+ 1
139
+ end
174
140
 
175
- Output.success("Deleted attachment #{attachment_id}")
176
- 0
141
+ def command_error(message)
142
+ output.error(message)
143
+ 1
177
144
  end
178
145
 
179
146
  def format_size(bytes)
180
- if bytes >= 1024 * 1024
181
- format('%.1f MB', bytes.to_f / (1024 * 1024))
182
- elsif bytes >= 1024
183
- format('%.1f KB', bytes.to_f / 1024)
184
- else
185
- "#{bytes} bytes"
186
- end
147
+ return format('%.1f MB', bytes.to_f / (1024 * 1024)) if bytes >= 1024 * 1024
148
+ return format('%.1f KB', bytes.to_f / 1024) if bytes >= 1024
149
+
150
+ "#{bytes} bytes"
187
151
  end
188
152
  end
189
153
  end
@@ -4,118 +4,91 @@ require_relative 'base'
4
4
 
5
5
  module Fp
6
6
  module Commands
7
- # List attachments for a requirement, or get a download URL for one.
8
- #
9
- # Usage:
10
- # fp attachments <slug> List attachments for a requirement
11
- # fp attachments <slug> <id> Get download URL for an attachment
7
+ # List a requirement's attachments, or get a short-lived URL for one.
12
8
  class Attachments < Base
13
- def run(args)
14
- slug = args[0]
15
- attachment_id = args[1]
16
-
17
- if slug.nil? || slug.empty?
18
- Output.error('Missing requirement slug. Usage: fp attachments <slug> [<id>]')
19
- return 1
20
- end
9
+ KNOWN_FLAGS = { project: :string }.freeze
21
10
 
22
- project_id = require_project_id!
23
- return 1 unless project_id
24
-
25
- # Resolve requirement
26
- result = client.find_requirement_by_slug(project_id, slug)
27
- unless result[:ok]
28
- Output.error(result[:error])
11
+ def run(args)
12
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
13
+ slug, attachment_id = positional
14
+ if slug.to_s.empty?
15
+ output.error('Missing requirement slug. Usage: fp attachments <slug> [<id>] [--project <project>]')
29
16
  return 1
30
17
  end
31
18
 
32
- requirement = result[:data]['requirement']
33
- requirement_id = requirement['id']
19
+ require_project!(opts)
20
+ project_id = resolve_project_id(opts[:project])
21
+ requirement_result = client.find_requirement_by_slug(project_id, slug)
22
+ return api_error(requirement_result) unless requirement_result[:ok]
34
23
 
35
- if attachment_id
36
- show_attachment(requirement_id, attachment_id, slug)
37
- else
38
- list_attachments(requirement_id, slug)
39
- end
24
+ requirement_id = requirement_result[:data].dig('requirement', 'id')
25
+ attachment_id ? show_attachment(requirement_id, attachment_id) : list_attachments(requirement_id, slug)
40
26
  end
41
27
 
42
28
  private
43
29
 
44
30
  def list_attachments(requirement_id, slug)
45
31
  result = client.list_attachments(requirement_id)
46
-
47
- unless result[:ok]
48
- Output.error(result[:error])
49
- return 1
50
- end
32
+ return api_error(result) unless result[:ok]
51
33
 
52
34
  attachments = result[:data]['attachments'] || []
53
-
54
- if json_output?
55
- Output.json({ attachments: attachments })
56
- return 0
57
- end
58
-
59
- if attachments.empty?
60
- Output.info("No attachments for requirement '#{slug}'")
61
- return 0
62
- end
63
-
64
- Output.info("Attachments for '#{slug}':\n")
65
- attachments.each do |att|
66
- puts " #{att['id']}"
67
- puts " Filename: #{att['filename']}"
68
- puts " Type: #{att['content_type']}"
69
- puts " Size: #{format_size(att['size_bytes'])}"
70
- puts " Uploaded by: #{att['uploaded_by']}"
71
- puts " Uploaded at: #{att['created_at']}"
72
- puts
35
+ output.success({ attachments: attachments }) do
36
+ if attachments.empty?
37
+ puts "No attachments for requirement '#{slug}'."
38
+ else
39
+ puts "Attachments for '#{slug}':"
40
+ attachments.each do |attachment|
41
+ puts
42
+ puts " #{attachment['id']} #{attachment['filename']} (#{format_size(attachment['size_bytes'])})"
43
+ puts " Surface: #{attachment['surface']}" if attachment['surface']
44
+ puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
45
+ puts " Deployment: #{deployment_label(attachment)}"
46
+ puts " Test run: #{attachment['test_run_url']}" if attachment['test_run_url']
47
+ end
48
+ end
73
49
  end
74
-
75
50
  0
76
51
  end
77
52
 
78
- def show_attachment(requirement_id, attachment_id, slug)
53
+ def show_attachment(requirement_id, attachment_id)
79
54
  result = client.get_attachment(requirement_id, attachment_id)
80
-
81
- unless result[:ok]
82
- Output.error(result[:error])
83
- return 1
84
- end
55
+ return api_error(result) unless result[:ok]
85
56
 
86
57
  data = result[:data]['attachment_response'] || result[:data]
87
58
  attachment = data['attachment']
88
- download_url = data['download_url']
89
- expires_in = data['expires_in']
90
-
91
- if json_output?
92
- Output.json(data)
93
- return 0
59
+ output.success(data) do
60
+ puts "Attachment: #{attachment['filename']}"
61
+ puts " Type: #{attachment['content_type']}"
62
+ puts " Size: #{format_size(attachment['size_bytes'])}"
63
+ puts " Surface: #{attachment['surface']}" if attachment['surface']
64
+ puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
65
+ puts " Deployment: #{deployment_label(attachment)}"
66
+ puts
67
+ puts "View URL (expires in #{data['expires_in'].to_i / 60} minutes):"
68
+ puts data['download_url']
94
69
  end
70
+ 0
71
+ end
95
72
 
96
- Output.info("Attachment: #{attachment['filename']}")
97
- puts " ID: #{attachment['id']}"
98
- puts " Type: #{attachment['content_type']}"
99
- puts " Size: #{format_size(attachment['size_bytes'])}"
100
- puts " Uploaded by: #{attachment['uploaded_by']}"
101
- puts " Uploaded at: #{attachment['created_at']}"
102
- puts
103
- puts "Download URL (expires in #{expires_in / 60} minutes):"
104
- puts download_url
73
+ def deployment_label(attachment)
74
+ case attachment['deployment_status']
75
+ when 'after_deployment' then "captured after deploy#{" at #{attachment['deployed_at']}" if attachment['deployed_at']}"
76
+ when 'predates_deployment' then "capture predates deploy#{" at #{attachment['deployed_at']}" if attachment['deployed_at']}"
77
+ else 'unknown'
78
+ end
79
+ end
105
80
 
106
- 0
81
+ def api_error(result)
82
+ output.error(result[:error], status: result[:status])
83
+ 1
107
84
  end
108
85
 
109
86
  def format_size(bytes)
110
- return '0 bytes' if bytes.nil? || bytes.zero?
87
+ bytes = bytes.to_i
88
+ return format('%.1f MB', bytes.to_f / (1024 * 1024)) if bytes >= 1024 * 1024
89
+ return format('%.1f KB', bytes.to_f / 1024) if bytes >= 1024
111
90
 
112
- if bytes >= 1024 * 1024
113
- format('%.1f MB', bytes.to_f / (1024 * 1024))
114
- elsif bytes >= 1024
115
- format('%.1f KB', bytes.to_f / 1024)
116
- else
117
- "#{bytes} bytes"
118
- end
91
+ "#{bytes} bytes"
119
92
  end
120
93
  end
121
94
  end
@@ -24,6 +24,7 @@ module Fp
24
24
  propose-change <slug> --removal Propose deleting a requirement
25
25
  comment <slug> --body "..." Comment on a requirement's open change proposal
26
26
  history <slug> --project <slug> Show a requirement's change history + diffs
27
+ analyze [<slug>] --project <slug> Triage requirement quality (read-only; never proposes)
27
28
  changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
28
29
  attach <slug> <file> Upload a file attachment to a requirement
29
30
  attachments <slug> List attachments for a requirement
@@ -267,11 +268,38 @@ module Fp
267
268
  fp history print_container_qr --project stowzilla
268
269
  fp history print_container_qr --project stowzilla --pending
269
270
 
270
- # Upload a file attachment to a requirement (screenshot evidence, docs, etc.)
271
- # Supported formats: png, jpg, gif, webp, svg, pdf, txt, md (max 10 MB)
272
- fp attach print_container_qr screenshot.png --project stowzilla
271
+ # Triage the catalog's quality: duplicates, fragments, vague wording, conflicts.
272
+ # One model call per uncached requirement; unchanged ones are read back free.
273
+ fp analyze --project stowzilla
274
+ fp analyze print_container_qr --project stowzilla --conflicts
275
+
276
+ # Upload visual test evidence to a requirement. Images and videos require
277
+ # --surface; deployment fields let reviewers verify exactly what build the
278
+ # capture observed. --example-title names the individual test the media
279
+ # proves, so it shows next to that test's receipt instead of a surface-wide
280
+ # gallery. --evidence-key is a stronger join: it directly names the receipt
281
+ # by its composite key (surface:repo:test_type) so the match survives title
282
+ # renames.
283
+ #
284
+ # Visual formats: png, jpg, gif, webp, mp4, webm, mov
285
+ # Text formats: txt, md, json (render inline as code blocks for API evidence)
286
+ # Documents: svg, pdf (downloadable)
287
+ # Max 100 MB.
288
+ fp attach print_container_qr e2e-run.webm --project stowzilla \\
289
+ --surface customer_web \\
290
+ --example-title "prints a QR code onto the container label" \\
291
+ --captured-at 2026-10-15T01:42:00Z \\
292
+ --deployed-at 2026-10-15T01:30:00Z \\
293
+ --sha abc1234 \\
294
+ --deployment-url https://preview.example.test \\
295
+ --test-run-url https://github.com/stowzilla/app/actions/runs/123
296
+
297
+ # For API tests, attach the JSON response as inline evidence
298
+ fp attach api_returns_order_details response.json --project stowzilla \\
299
+ --surface api \\
300
+ --evidence-key "api:stowzilla/api:e2e"
273
301
 
274
- # List attachments for a requirement
302
+ # List attachments and their capture/deployment context
275
303
  fp attachments print_container_qr --project stowzilla
276
304
 
277
305
  # Get a download URL for a specific attachment
@@ -86,7 +86,7 @@ module Fp
86
86
  params[:work_item_url] = opts[:work_item] if opts[:work_item]
87
87
  params[:ci_url] = opts[:ci_url] if opts[:ci_url]
88
88
  params[:title] = opts[:title] if opts[:title]
89
- params[:line] = opts[:line] if opts[:line]
89
+ params[:line] = opts[:line].to_i if opts[:line]
90
90
 
91
91
  # Try to report evidence
92
92
  result = client.report_evidence(params)
data/lib/fp/commands.rb CHANGED
@@ -9,6 +9,7 @@ require_relative 'commands/propose'
9
9
  require_relative 'commands/propose_change'
10
10
  require_relative 'commands/comment'
11
11
  require_relative 'commands/history'
12
+ require_relative 'commands/analyze'
12
13
  require_relative 'commands/changes'
13
14
  require_relative 'commands/report'
14
15
  require_relative 'commands/ci_report'
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.17'
4
+ VERSION = '0.0.18'
5
5
  end
data/skills/fp/SKILL.md CHANGED
@@ -350,6 +350,37 @@ FeatureParity section in `AGENTS.md`):
350
350
  fp init --project <slug> --surface <surface>
351
351
  ```
352
352
 
353
+ ### Triage requirement quality (`fp analyze`)
354
+
355
+ `fp analyze` checks how well requirements are written and whether they belong in the
356
+ catalog at all. It is **read-only: it never proposes anything.** Sweep first, report, and
357
+ only file the proposals a human picks — every proposal moves its requirement back to draft
358
+ and marks its evidence `suspect`, and a requirement holds one open proposal at a time, so
359
+ proposing on every flag could put dozens of requirements in review at once.
360
+
361
+ ```bash
362
+ fp analyze --project stowzilla # sweep active + draft requirements
363
+ fp analyze print_container_qr --project stowzilla # one requirement, with suggestions
364
+ fp analyze --project stowzilla --conflicts # also check for contradictions (slower)
365
+ fp analyze --project stowzilla --cached # read saved results only, no model calls
366
+ fp analyze --project stowzilla --json
367
+ ```
368
+
369
+ It goes one requirement at a time. Results are cached until the wording changes, so a
370
+ re-run only pays for requirements that are new or edited. Requirements with a change
371
+ already awaiting review are skipped.
372
+
373
+ Each finding suggests a kind of follow-up. Treat it as a signal that something is wrong,
374
+ not as the fix — you still write the proposal.
375
+
376
+ | Finding | Suggested follow-up |
377
+ |---------|---------------------|
378
+ | Possible conflict with another requirement | Stop and ask a human — a contradiction is a product decision. Read both requirements first; the analyzer says *that* they clash, not *why* |
379
+ | Duplicate (the report names `superseded by: <slug>`) | `fp propose-change <slug> --removal --reason "superseded by <other>"` |
380
+ | Fragment of a broader promise (`is_focused_promise` fails) | Fold it into the parent's acceptance: `fp propose-change <parent>` for the wording, then `--removal` for the fragment |
381
+ | Not a product promise / not observable | A human decides whether it belongs; don't guess |
382
+ | Missing why, vague acceptance, implementation detail, boilerplate | `fp propose-change <slug> --why "..." --acceptance "..."` |
383
+
353
384
  ## Quick reference
354
385
 
355
386
  | Task | Command |
@@ -364,6 +395,7 @@ fp init --project <slug> --surface <surface>
364
395
  | Propose a change | `fp propose-change <slug> --project X --why "..." --reason "..."` |
365
396
  | Propose a deletion | `fp propose-change <slug> --project X --removal --reason "..."` |
366
397
  | View change history | `fp history <slug> --project X` |
398
+ | Triage requirement quality | `fp analyze --project X` |
367
399
  | View work-item activity | `fp changes --project X --source-url URL` |
368
400
  | Enforce parity | `fp check --project X` |
369
401
  | Gate on this run | `fp check --project X --surface Y --junit file.xml` |
@@ -276,6 +276,37 @@ the review decision where one was made. An open proposal is called out at the en
276
276
 
277
277
  `--project` is optional when the repo has a `.featureparity.yml`.
278
278
 
279
+ ### fp analyze
280
+
281
+ Triage requirement quality: duplicates, fragments of a broader promise, vague wording,
282
+ possible conflicts. **Read-only — it never files a proposal.** Report first; a human
283
+ picks which proposals to file (each moves its requirement to draft).
284
+
285
+ ```bash
286
+ fp analyze --project stowzilla # active + draft requirements
287
+ fp analyze print_container_qr --project stowzilla # one requirement, with suggestions
288
+ fp analyze --project stowzilla --status active # narrow the sweep
289
+ fp analyze --project stowzilla --conflicts # also check contradictions (slower)
290
+ fp analyze --project stowzilla --force # re-run even if the result is current
291
+ fp analyze --project stowzilla --cached # saved results only, no model calls
292
+ fp analyze --project stowzilla --json
293
+ ```
294
+
295
+ Analysis costs one model call per requirement, so the sweep runs one request at a time
296
+ rather than asking the server to do the whole catalog at once (which cannot finish inside
297
+ the API timeout). Results are cached until a requirement's title, why, or acceptance
298
+ changes, so re-running only pays for new or edited requirements. A requirement with a
299
+ change awaiting review is skipped.
300
+
301
+ The report groups findings by suggested follow-up: **conflict** (ask a human),
302
+ **propose removal** (duplicate — with `superseded by: <slug>` — or a fragment of a broader
303
+ promise), **needs human** (not clearly a product promise), **propose change** (wording
304
+ issues), plus in-review, not-analyzed (`--cached` only), and error. `--conflicts` adds
305
+ the other requirement's slug, a probability, and both acceptance clauses side by side —
306
+ the analyzer flags the clash but cannot explain it.
307
+
308
+ `--project` is optional when the repo has a `.featureparity.yml`.
309
+
279
310
  ### fp changes
280
311
 
281
312
  Show requirement activity prompted by a work item, rather than opening every requirement's
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.17
4
+ version: 0.0.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-14 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http
@@ -94,6 +94,7 @@ files:
94
94
  - lib/fp/cli.rb
95
95
  - lib/fp/client.rb
96
96
  - lib/fp/commands.rb
97
+ - lib/fp/commands/analyze.rb
97
98
  - lib/fp/commands/attach.rb
98
99
  - lib/fp/commands/attachments.rb
99
100
  - lib/fp/commands/base.rb