featureparity 0.0.21 → 0.0.22

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: 6ae3f4be081207da9feb300fbdfaad6d2afddf2ffb021ffc8136508da964f0c2
4
- data.tar.gz: 74d2cf3feb18ea77cac72f38be15c5eb6870d237d76f2a21a3b859853649b02d
3
+ metadata.gz: b06053deae8d893e793e13d2dd56773db5ff1ce04d2cc5f2be40b65da3520d11
4
+ data.tar.gz: 0c7d0b2ae20e481fedab84bf188d7a3b8c3ae47d91c0b16c816d49e697d2f16a
5
5
  SHA512:
6
- metadata.gz: f14f50d9f5e6d620341081a55ee98734bafd6893ade078cb424246c6ddb16a2f85a8aff6b5d28dc1d969505708b0e59806628699c47146736b024f90c0e0c6e6
7
- data.tar.gz: 156d5c4e679017b8d5ec7825eb51d7eb64acec51daf02700a263f58e20251c2136efadab399244856f4ce943fe832825e89e45a75f57a814368e8dbc54a5189e
6
+ metadata.gz: b8f6ae3cedd0abb1c6a1cc248092d83f1643858d08aee76bd4d6707f1a5072bbc1f06d0ba83ed35cc79cd36afd24b543e625d58c6f9044c102f47b77a4cc2d79
7
+ data.tar.gz: c938e91cd9bbf92197bb0a85dcb6488d0c0621ed6760f12bdac0e4c23002bac09ca7232d3a12d93618011261d90150f98a02dc861e0f2b893bc8ac9f003d0b10
data/lib/fp/cli.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
4
+
3
5
  module Fp
4
6
  # Main CLI entry point - parses global flags and dispatches to commands
5
7
  class CLI
@@ -7,6 +9,7 @@ module Fp
7
9
  'init' => Commands::Init,
8
10
  'projects' => Commands::Projects,
9
11
  'surfaces' => Commands::Surfaces,
12
+ 'showroom' => Commands::Showroom,
10
13
  'list' => Commands::List,
11
14
  'show' => Commands::Show,
12
15
  'propose' => Commands::Propose,
@@ -39,6 +42,12 @@ module Fp
39
42
  global_opts = extract_global_options(args)
40
43
 
41
44
  command_name = args.shift || 'help'
45
+
46
+ # `fp --version` / `fp -v` are aliases for `fp version` (Fizzy #1489 ask 3).
47
+ # A bare version flag is the universal expectation for a CLI; without this it
48
+ # reported "Unknown command: --version".
49
+ command_name = 'version' if %w[--version -v].include?(command_name)
50
+
42
51
  command_class = COMMANDS[command_name]
43
52
 
44
53
  unless command_class
@@ -48,6 +57,15 @@ module Fp
48
57
  exit 1
49
58
  end
50
59
 
60
+ # Per-command help (Fizzy #1489): `fp <cmd> --help` / `fp <cmd> -h` prints the
61
+ # command's own usage and flags instead of running it. Detected here, before
62
+ # auth, so `fp report --help` works with no API key — the whole point is to let
63
+ # someone discover a command's flags before they can run it.
64
+ if wants_help?(args)
65
+ print_command_help(command_name, command_class, json: global_opts[:json])
66
+ return
67
+ end
68
+
51
69
  # Profile/config commands don't need auth. `init` is here too: it only writes
52
70
  # files into the repo, and onboarding a repo must work before anyone has a key.
53
71
  needs_auth = !%w[init profile config repos setup whoami version help].include?(command_name)
@@ -78,6 +96,28 @@ module Fp
78
96
 
79
97
  private
80
98
 
99
+ # True when the remaining args ask for help on the command: `--help` or `-h`
100
+ # anywhere in the args. We don't treat a lone `help` positional specially here —
101
+ # that's a real subcommand for some commands.
102
+ def wants_help?(args)
103
+ args.include?('--help') || args.include?('-h')
104
+ end
105
+
106
+ # Print a command's own help page. `fp help <cmd>` routes here too, via the Help
107
+ # command. Commands that haven't adopted the help DSL still get a usable page
108
+ # (summary falls back to a generic line, flags to the global set).
109
+ #
110
+ # With --json, emit the command's flag schema instead of the human page (Fizzy
111
+ # #1489 ask 4) so an agent or the skill can read a command's flags, required-ness,
112
+ # and usage straight from the gem rather than scraping KNOWN_FLAGS.
113
+ def print_command_help(command_name, command_class, json: false)
114
+ if json
115
+ puts JSON.pretty_generate(command_class.help_schema(command_name))
116
+ else
117
+ puts command_class.help_text(command_name)
118
+ end
119
+ end
120
+
81
121
  def extract_global_options(args)
82
122
  opts = { json: false, profile: nil, api_url: nil }
83
123
 
data/lib/fp/client.rb CHANGED
@@ -82,6 +82,20 @@ module Fp
82
82
  delete("/api/projects/#{project_id}/surfaces/#{surface_id}")
83
83
  end
84
84
 
85
+ # GET /projects/:id/showroom
86
+ # The client showroom as a client would see it (Fizzy #1479). Open to the team and
87
+ # their agents; the response carries only what a client is shown.
88
+ def get_showroom(project_id)
89
+ get("/api/projects/#{project_id}/showroom")
90
+ end
91
+
92
+ # GET /projects/:id/showroom/share_link
93
+ # The share link, or null when sharing is off. Humans only: an agent's key gets 403,
94
+ # which `fp showroom` reports as "unknown" rather than treating as an error.
95
+ def get_showroom_share_link(project_id)
96
+ get("/api/projects/#{project_id}/showroom/share_link")
97
+ end
98
+
85
99
  # GET /requirements
86
100
  def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
87
101
  params = {}
@@ -179,6 +193,16 @@ module Fp
179
193
  post("/api/requirements/#{requirement_id}/changes/#{change_id}/comment", params)
180
194
  end
181
195
 
196
+ # DELETE /requirements/:id/changes/:change_id
197
+ #
198
+ # Withdraw an open proposal. Only the proposal's author can withdraw it; the
199
+ # requirement is restored to what it was before the proposal (the same rollback a
200
+ # reject performs). This is how an agent *amends* a proposal under the one-open-
201
+ # proposal rule: withdraw, then propose again.
202
+ def withdraw_requirement_change(requirement_id, change_id)
203
+ delete("/api/requirements/#{requirement_id}/changes/#{change_id}")
204
+ end
205
+
182
206
  # GET /projects/:project_id/evidence — the receipts already filed for a project,
183
207
  # optionally narrowed to one requirement slug and/or surface.
184
208
  def list_evidence(project_id, slug: nil, surface: nil)
@@ -15,6 +15,16 @@ module Fp
15
15
  # put dozens of requirements up for review at once. The report says what *kind* of
16
16
  # follow-up each finding suggests; a human picks which proposals get filed.
17
17
  class Analyze < Base
18
+ help_summary 'Triage requirement quality (read-only; never proposes or files anything)'
19
+ help_usage 'fp analyze [<slug>] --project <slug> [--status S] [--conflicts] [--cached]'
20
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
21
+ help_flag :status, 'Limit to requirements with this status'
22
+ help_flag :conflicts, 'Include cross-requirement conflict findings'
23
+ help_flag :force, 'Re-analyze even cached (unchanged) requirements'
24
+ help_flag :cached, 'Read cached analyses only; do not spend model calls'
25
+ help_example 'fp analyze --project stowzilla'
26
+ help_example 'fp analyze print_container_qr --project stowzilla --conflicts'
27
+
18
28
  KNOWN_FLAGS = {
19
29
  project: :string,
20
30
  status: :string,
@@ -6,6 +6,22 @@ module Fp
6
6
  module Commands
7
7
  # Upload visual test evidence or another supporting file to a requirement.
8
8
  class Attach < Base
9
+ help_summary 'Upload screenshot/video/file evidence to a requirement (rm to delete)'
10
+ help_usage 'fp attach <slug> <file> --project <slug> --surface <surface> [flags]',
11
+ 'fp attach rm <slug> <attachment-id> --project <slug>'
12
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
13
+ help_flag :surface, 'Surface the capture is for (required for images/videos)'
14
+ help_flag :captured_at, 'ISO8601 timestamp the capture was taken'
15
+ help_flag :deployed_at, 'ISO8601 timestamp of the deployment the capture observed'
16
+ help_flag :deployment_url, 'URL of the deployment under test'
17
+ help_flag :test_run_url, 'CI run / test-run URL'
18
+ help_flag :sha, 'Commit SHA the capture observed'
19
+ help_flag :example_title, 'Name of the individual test this media proves'
20
+ help_flag :evidence_key, 'Composite receipt key (surface:repo:test_type) to bind to'
21
+ help_example 'fp attach print_qr e2e-run.webm --project stowzilla --surface customer_web --example-title "prints a QR code onto the container label"'
22
+ help_example 'fp attach rm print_qr <attachment-id> --project stowzilla'
23
+ help_note 'Visual: png, jpg, gif, webp, mp4, webm, mov. Text/docs: txt, md, json, svg, pdf. Max 100 MB.'
24
+
9
25
  CONTENT_TYPES = {
10
26
  'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
11
27
  'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml',
@@ -31,58 +47,88 @@ module Fp
31
47
  opts, positional = parse_flags(args, KNOWN_FLAGS)
32
48
  return delete_attachment(opts, positional.drop(1)) if %w[rm delete].include?(positional.first)
33
49
 
34
- upload_attachment(opts, positional)
50
+ upload_attachments(opts, positional)
35
51
  end
36
52
 
37
53
  private
38
54
 
39
- def upload_attachment(opts, positional)
40
- slug, file_path = positional
55
+ # fp attach <slug> <file> [<file> ...]
56
+ # Several files attach in one command: each is uploaded to the same requirement with
57
+ # the shared flags (surface, bound test, deployment context). A failure on one file
58
+ # doesn't abort the rest, and the exit status reflects whether every upload succeeded.
59
+ def upload_attachments(opts, positional)
60
+ slug = positional.first
61
+ file_paths = positional.drop(1)
41
62
  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)
63
+ return usage_error('Missing file path') if file_paths.empty?
64
+
65
+ # Validate every file up front and resolve the requirement once, so a bad path in a
66
+ # batch is reported before any upload begins and all files share one lookup.
67
+ prepared = file_paths.map { |path| prepare_file(path, opts) }
68
+ invalid = prepared.find { |p| p[:error] }
69
+ return command_error(invalid[:error]) if invalid
70
+
71
+ require_project!(opts)
72
+ project_id = resolve_project_id(opts[:project])
73
+ requirement_result = client.find_requirement_by_slug(project_id, slug)
74
+ return api_error(requirement_result) unless requirement_result[:ok]
75
+
76
+ requirement_id = requirement_result[:data].dig('requirement', 'id')
77
+
78
+ results = prepared.map { |file| upload_one(requirement_id, slug, file, opts) }
79
+ report_batch(results, slug)
80
+ end
81
+
82
+ # Validate a single file and compute its upload metadata. Returns a hash carrying
83
+ # either an :error string or the resolved filename/extension/content_type/size.
84
+ def prepare_file(file_path, opts)
85
+ return { error: "File not found: #{file_path}" } unless File.file?(file_path)
44
86
 
45
87
  filename = File.basename(file_path)
46
88
  extension = File.extname(filename).delete('.').downcase
47
89
  content_type = CONTENT_TYPES[extension]
48
90
  unless content_type
49
- return command_error("Unsupported file type: .#{extension}. Allowed: #{CONTENT_TYPES.keys.join(', ')}")
91
+ return { error: "Unsupported file type: .#{extension}. Allowed: #{CONTENT_TYPES.keys.join(', ')}" }
50
92
  end
51
93
 
52
94
  size = File.size(file_path)
53
- return command_error("File is empty: #{file_path}") if size.zero?
95
+ return { error: "File is empty: #{file_path}" } if size.zero?
54
96
  if size > MAX_FILE_SIZE
55
- return command_error("File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})")
97
+ return { error: "File too large: #{format_size(size)} (max #{format_size(MAX_FILE_SIZE)})" }
56
98
  end
57
99
  if VISUAL_EXTENSIONS.include?(extension) && opts[:surface].to_s.empty?
58
- return command_error('--surface is required for screenshot and video evidence')
100
+ return { error: '--surface is required for screenshot and video evidence' }
59
101
  end
60
102
 
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]
103
+ { path: file_path, filename: filename, extension: extension, content_type: content_type, size: size }
104
+ end
65
105
 
66
- requirement_id = requirement_result[:data].dig('requirement', 'id')
106
+ # Upload one prepared file. Returns { ok:, filename:, attachment: } or
107
+ # { ok: false, filename:, error: } so the batch can report per-file outcomes.
108
+ def upload_one(requirement_id, slug, file, opts)
67
109
  presign_result = client.presign_attachment(
68
110
  requirement_id,
69
- filename: filename,
70
- content_type: content_type,
71
- size_bytes: size
111
+ filename: file[:filename],
112
+ content_type: file[:content_type],
113
+ size_bytes: file[:size]
72
114
  )
73
- return api_error(presign_result, prefix: 'Failed to get upload URL') unless presign_result[:ok]
115
+ unless presign_result[:ok]
116
+ return { ok: false, filename: file[:filename], error: error_text(presign_result, 'Failed to get upload URL') }
117
+ end
74
118
 
75
119
  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]
120
+ upload_result = client.upload_to_presigned_url(presign['upload_url'], file[:path], file[:content_type])
121
+ unless upload_result[:ok]
122
+ return { ok: false, filename: file[:filename], error: error_text(upload_result, 'Upload failed') }
123
+ end
78
124
 
79
125
  create_result = client.create_attachment(
80
126
  requirement_id,
81
127
  attachment_id: presign['attachment_id'],
82
128
  s3_key: presign['s3_key'],
83
- filename: filename,
84
- content_type: content_type,
85
- size_bytes: size,
129
+ filename: file[:filename],
130
+ content_type: file[:content_type],
131
+ size_bytes: file[:size],
86
132
  surface: opts[:surface],
87
133
  captured_at: opts[:captured_at],
88
134
  deployed_at: opts[:deployed_at],
@@ -92,20 +138,41 @@ module Fp
92
138
  example_title: opts[:example_title],
93
139
  evidence_key: opts[:evidence_key]
94
140
  )
95
- return api_error(create_result, prefix: 'Failed to confirm upload') unless create_result[:ok]
96
-
97
- attachment = create_result[:data]['attachment']
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?
141
+ unless create_result[:ok]
142
+ return { ok: false, filename: file[:filename], error: error_text(create_result, 'Failed to confirm upload') }
143
+ end
144
+
145
+ { ok: true, filename: file[:filename], extension: file[:extension], attachment: create_result[:data]['attachment'] }
146
+ end
147
+
148
+ def report_batch(results, slug)
149
+ uploaded = results.select { |r| r[:ok] }
150
+ failed = results.reject { |r| r[:ok] }
151
+
152
+ output.success({ slug: slug, uploaded: uploaded.map { |r| r[:attachment] }, failed: failed.map { |r| { filename: r[:filename], error: r[:error] } } }) do
153
+ uploaded.each { |r| print_uploaded(r, slug) }
154
+ failed.each { |r| $stderr.puts "Failed: #{r[:filename]}: #{r[:error]}" }
155
+ if results.length > 1
156
+ puts
157
+ puts "#{uploaded.length} of #{results.length} file(s) uploaded to requirement '#{slug}'."
158
+ end
159
+ end
160
+
161
+ failed.empty? ? 0 : 1
162
+ end
163
+
164
+ def print_uploaded(result, slug)
165
+ attachment = result[:attachment]
166
+ puts "Uploaded #{result[:filename]} to requirement '#{slug}'."
167
+ puts " ID: #{attachment['id']}"
168
+ puts " Surface: #{attachment['surface']}" if attachment['surface']
169
+ puts " Test: #{attachment['example_title']}" if attachment['example_title']
170
+ puts " Evidence key: #{attachment['evidence_key']}" if attachment['evidence_key']
171
+ puts " Captured: #{attachment['captured_at']}" if attachment['captured_at']
172
+ puts " Deploy: #{attachment['deployment_url']}" if attachment['deployment_url']
173
+ if VISUAL_EXTENSIONS.include?(result[:extension]) && attachment['deployed_at'].to_s.empty?
174
+ puts ' Deployment context: unknown'
107
175
  end
108
- 0
109
176
  end
110
177
 
111
178
  def delete_attachment(opts, positional)
@@ -128,16 +195,19 @@ module Fp
128
195
 
129
196
  def usage_error(message, delete: false)
130
197
  usage = delete ? 'fp attach rm <slug> <id> [--project <project>]' :
131
- 'fp attach <slug> <file> [--surface <surface>] [--project <project>]'
198
+ 'fp attach <slug> <file> [<file> ...] [--surface <surface>] [--project <project>]'
132
199
  command_error("#{message}. Usage: #{usage}")
133
200
  end
134
201
 
135
202
  def api_error(result, prefix: nil)
136
- message = [prefix, result[:error]].compact.join(': ')
137
- output.error(message, status: result[:status])
203
+ output.error(error_text(result, prefix), status: result[:status])
138
204
  1
139
205
  end
140
206
 
207
+ def error_text(result, prefix = nil)
208
+ [prefix, result[:error]].compact.join(': ')
209
+ end
210
+
141
211
  def command_error(message)
142
212
  output.error(message)
143
213
  1
@@ -6,6 +6,12 @@ module Fp
6
6
  module Commands
7
7
  # List a requirement's attachments, or get a short-lived URL for one.
8
8
  class Attachments < Base
9
+ help_summary "List a requirement's attachments, or get a download URL for one"
10
+ help_usage 'fp attachments <slug> --project <slug>',
11
+ 'fp attachments <slug> <attachment-id> --project <slug>'
12
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
13
+ help_example 'fp attachments print_container_qr --project stowzilla'
14
+
9
15
  KNOWN_FLAGS = { project: :string }.freeze
10
16
 
11
17
  def run(args)
@@ -4,6 +4,142 @@ module Fp
4
4
  module Commands
5
5
  # Base class for all commands
6
6
  class Base
7
+ # --- Per-command help (Fizzy #1489) -------------------------------------
8
+ #
9
+ # Every command declares its own usage, a one-line summary, its flags, and a
10
+ # few examples here, so `fp <cmd> --help`, `fp <cmd> -h`, and `fp help <cmd>`
11
+ # can all print them. Before this, a command's flags were only discoverable by
12
+ # reading KNOWN_FLAGS in the gem — `fp report --help` just ran `fp report` and
13
+ # failed validation.
14
+ #
15
+ # This is a small declarative DSL rather than a scrape of KNOWN_FLAGS on
16
+ # purpose: the flag constants are named inconsistently across commands
17
+ # (KNOWN_FLAGS, ADD_FLAGS, CREATE_FLAGS, FIELD_FLAGS...), some commands take
18
+ # positional subcommands with no constant at all, and — most importantly — a
19
+ # bare list of flag *names* barely improves on "read the gem". The value an
20
+ # agent needs is what each flag means and which are required, which only a
21
+ # written description carries.
22
+ class << self
23
+ # A one-line description shown in `fp help` and at the top of per-command help.
24
+ def help_summary(text = nil)
25
+ @help_summary = text if text
26
+ @help_summary
27
+ end
28
+
29
+ # One or more usage lines, e.g. "fp report <slug> --project <slug> --surface X".
30
+ def help_usage(*lines)
31
+ @help_usage ||= []
32
+ @help_usage.concat(lines.flatten) unless lines.empty?
33
+ @help_usage
34
+ end
35
+
36
+ # Declare a flag: name (symbol or string), a description, and whether it's
37
+ # required. Order of declaration is the order shown.
38
+ def help_flag(name, description, required: false)
39
+ @help_flags ||= []
40
+ @help_flags << { name: name.to_s.tr('_', '-'), description: description, required: required }
41
+ end
42
+
43
+ def help_flags
44
+ @help_flags ||= []
45
+ end
46
+
47
+ # Example invocations, each an array [command_line, optional_comment].
48
+ def help_example(command_line, comment = nil)
49
+ @help_examples ||= []
50
+ @help_examples << { command: command_line, comment: comment }
51
+ end
52
+
53
+ def help_examples
54
+ @help_examples ||= []
55
+ end
56
+
57
+ # A short free-form note shown after the flags (caveats, related commands).
58
+ def help_note(text = nil)
59
+ @help_note = text if text
60
+ @help_note
61
+ end
62
+
63
+ # True when a command has opted into the help DSL. Commands that haven't
64
+ # still get a generic help page listing global flags.
65
+ def help_defined?
66
+ !help_summary.nil? || !help_usage.empty? || !help_flags.empty?
67
+ end
68
+
69
+ # Render the full help page for this command.
70
+ # command_name - the dispatch name (e.g. "ci-report"), so usage lines and
71
+ # the header match what the user typed.
72
+ def help_text(command_name)
73
+ lines = []
74
+ lines << "fp #{command_name} - #{help_summary}" if help_summary
75
+ lines << '' unless lines.empty?
76
+
77
+ usage = help_usage.empty? ? ["fp #{command_name} [flags]"] : help_usage
78
+ lines << 'USAGE'
79
+ usage.each { |u| lines << " #{u}" }
80
+
81
+ unless help_flags.empty?
82
+ lines << ''
83
+ lines << 'FLAGS'
84
+ width = help_flags.map { |f| f[:name].length }.max
85
+ help_flags.each do |f|
86
+ label = "--#{f[:name].ljust(width)}"
87
+ req = f[:required] ? ' (required)' : ''
88
+ lines << " #{label} #{f[:description]}#{req}"
89
+ end
90
+ end
91
+
92
+ lines << ''
93
+ lines << 'GLOBAL FLAGS'
94
+ lines << ' --json Output as a JSON envelope'
95
+ lines << ' --profile <name> Use a named profile (-p for short)'
96
+ lines << ' --api-url <url> Override the API URL for this command'
97
+
98
+ unless help_examples.empty?
99
+ lines << ''
100
+ lines << 'EXAMPLES'
101
+ help_examples.each do |ex|
102
+ lines << " # #{ex[:comment]}" if ex[:comment]
103
+ lines << " #{ex[:command]}"
104
+ end
105
+ end
106
+
107
+ if help_note
108
+ lines << ''
109
+ help_note.each_line { |l| lines << " #{l.chomp}" }
110
+ end
111
+
112
+ lines.join("\n")
113
+ end
114
+
115
+ # The command's flag schema as a plain hash (Fizzy #1489 ask 4). `--json` on a
116
+ # help invocation emits this, so agents and the Agent Skills package can read a
117
+ # command's real flags — names, descriptions, required-ness, usage, examples —
118
+ # straight from the installed gem and stay in sync with it automatically.
119
+ #
120
+ # Global flags are reported separately from the command's own so a consumer can
121
+ # tell "every command accepts --json" from "this command accepts --surface".
122
+ def help_schema(command_name)
123
+ {
124
+ 'command' => command_name,
125
+ 'summary' => help_summary,
126
+ 'usage' => (help_usage.empty? ? ["fp #{command_name} [flags]"] : help_usage),
127
+ 'flags' => help_flags.map do |f|
128
+ { 'name' => f[:name], 'description' => f[:description], 'required' => f[:required] }
129
+ end,
130
+ 'global_flags' => [
131
+ { 'name' => 'json', 'description' => 'Output as a JSON envelope', 'required' => false },
132
+ { 'name' => 'profile', 'description' => 'Use a named profile (-p for short)', 'required' => false },
133
+ { 'name' => 'api-url', 'description' => 'Override the API URL for this command', 'required' => false }
134
+ ],
135
+ 'examples' => help_examples.map do |ex|
136
+ { 'command' => ex[:command], 'comment' => ex[:comment] }
137
+ end,
138
+ 'note' => help_note
139
+ }
140
+ end
141
+ end
142
+
7
143
  def initialize(client: nil, output: nil, config: nil)
8
144
  @client = client
9
145
  @output = output
@@ -6,6 +6,18 @@ module Fp
6
6
  # exact candidate fingerprint.
7
7
  class ChangedTests < Base
8
8
  CLASSIFICATIONS = %w[still_proves no_longer_proves uncertain].freeze
9
+
10
+ help_summary 'List CI-detected revisions to bound tests, and assess a candidate'
11
+ help_usage 'fp changed-tests [--json]',
12
+ 'fp changed-tests assess <finding-id> --candidate-fingerprint <sha256> --classification C --explanation "..."'
13
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
14
+ help_flag :surface, 'Limit to one surface'
15
+ help_flag :test_type, 'Limit to one test type'
16
+ help_flag :classification, "Assessment: #{CLASSIFICATIONS.join(', ')}"
17
+ help_flag :explanation, 'Plain-language reason for the assessment'
18
+ help_flag :candidate_fingerprint, 'The exact SHA-256 being assessed'
19
+ help_example 'fp changed-tests'
20
+ help_example 'fp changed-tests assess abc123 --candidate-fingerprint <sha> --classification still_proves --explanation "Same result asserted"'
9
21
  KNOWN_FLAGS = {
10
22
  project: :string,
11
23
  surface: :string,
@@ -11,6 +11,15 @@ module Fp
11
11
  # Actions it can discover source URLs from the pull request body, append a job
12
12
  # summary, and keep a managed table in the pull request description itself.
13
13
  class Changes < Base
14
+ help_summary 'Show (or publish to a PR) requirement activity linked to a work item'
15
+ help_usage 'fp changes [--source-url URL]... [--github-pr] [--github-summary]'
16
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
17
+ help_flag :source_url, 'Work-item URL to match (repeatable; auto-discovered in a PR body otherwise)'
18
+ help_flag :github_pr, "Write a managed table into the pull request's description"
19
+ help_flag :github_summary, "Append the table to the GitHub Actions job summary"
20
+ help_example 'fp changes --source-url https://app.fizzy.do/123/cards/456'
21
+ help_example 'fp changes --github-pr --github-summary', 'In CI: keep a linked table in the PR'
22
+
14
23
  PR_SECTION_BEGIN = '<!-- feature-parity:requirement-changes:begin -->'
15
24
  PR_SECTION_END = '<!-- feature-parity:requirement-changes:end -->'
16
25
 
@@ -36,6 +36,21 @@ module Fp
36
36
  # one. Prefer setting the project's level to `regression` over living on --warn-only
37
37
  # forever, since a warn-only gate is one nobody reads.
38
38
  class Check < Base
39
+ help_summary 'Enforce parity; exits non-zero when the project violates its level'
40
+ help_usage 'fp check [--project <slug>] [--surface X] [--level L] [--junit FILE]... [--warn-only]'
41
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
42
+ help_flag :surface, 'Gate a single surface'
43
+ help_flag :test_type, 'Gate a single test type'
44
+ help_flag :level, 'Override the enforcement level for this run (off/regression/covered/strict)'
45
+ help_flag :junit, "Gate the branch on this run's results, overlaid on the grid (repeatable, one per surface)"
46
+ help_flag :base_dir, "Where the report's relative test-file paths resolve from"
47
+ help_flag :warn_only, 'Report violations but still exit 0'
48
+ help_example 'fp check', 'Gate on recorded state (project from .featureparity.yml)'
49
+ help_example 'fp check --surface api --junit junit.xml', "Gate a PR branch on its own results"
50
+ help_example 'fp check --warn-only --json'
51
+ help_note "Exit 0 = passed, 1 = violations (or request failed). The level lives on the project\n" \
52
+ 'server-side; --level only overrides this one run.'
53
+
39
54
  KNOWN_FLAGS = {
40
55
  project: :string,
41
56
  surface: :string,
@@ -182,11 +197,12 @@ module Fp
182
197
  end
183
198
 
184
199
  print_state_counts(summary)
200
+ print_scope_notice(report)
185
201
  print_violations(violations)
186
202
  print_orphans(report)
187
203
  print_pending_reviews(report, project_slug)
188
204
  print_unscoped_warning(summary, project_slug)
189
- print_verdict(passed, violations, opts)
205
+ print_verdict(passed, violations, opts, report)
190
206
  end
191
207
  end
192
208
 
@@ -198,6 +214,24 @@ module Fp
198
214
  puts
199
215
  end
200
216
 
217
+ # A surface-scoped run (--surface, or a single `surface:` in .featureparity.yml)
218
+ # only measures the one surface it was given. That's correct for a per-surface CI
219
+ # job, but it means the verdict covers a slice of the project — so a bare "Gate
220
+ # passed" would read as "the whole project is in parity" when other required
221
+ # surfaces were never looked at (Fizzy #1490). Name them up front, and the verdict
222
+ # line qualifies itself to match.
223
+ def print_scope_notice(report)
224
+ return unless report['scoped']
225
+
226
+ excluded = report['scoped_out_surfaces'] || []
227
+ return if excluded.empty?
228
+
229
+ puts "Scope: this check only evaluated the '#{report['surface']}' surface."
230
+ puts " #{excluded.size} other required surface(s) were NOT checked: #{excluded.join(', ')}."
231
+ puts ' Run fp check per surface (or without --surface) to cover the whole project.'
232
+ puts
233
+ end
234
+
201
235
  # Requirements a proposed change has knocked back to draft. Informational, never a
202
236
  # violation — a human's review latency is not something to fail an agent's build
203
237
  # over. But they stay in the grid (proposing a change must not be a way out of the
@@ -302,9 +336,13 @@ module Fp
302
336
  puts
303
337
  end
304
338
 
305
- def print_verdict(passed, violations, opts)
339
+ def print_verdict(passed, violations, opts, report = {})
340
+ # A scoped pass is not a project pass: say which surface it passed for, so nobody
341
+ # reads "Gate passed" as "the whole project is covered" (Fizzy #1490).
342
+ scope = report['scoped'] ? " for surface '#{report['surface']}' (other required surfaces not checked)" : ''
343
+
306
344
  if passed
307
- puts 'Gate passed.'
345
+ puts "Gate passed#{scope}."
308
346
  elsif opts[:warn_only]
309
347
  puts "Gate FAILED with #{violations.size} violation(s) — exiting 0 because --warn-only was given."
310
348
  else
@@ -50,6 +50,23 @@ module Fp
50
50
  # the default branch to record the real `passing`, superseding any pr_passing a prior
51
51
  # PR run left behind.
52
52
  class CiReport < Base
53
+ help_summary 'CI only: ingest real pass/fail results (writes passing/failing evidence)'
54
+ help_usage 'fp ci-report --junit <file>... --project <slug> --surface <surface> --ci-url URL [--pr URL]'
55
+ help_flag :project, 'Project slug (falls back to .featureparity.yml)'
56
+ help_flag :surface, 'Surface the suite covers', required: true
57
+ help_flag :test_type, 'Default test kind for markers that pin none'
58
+ help_flag :ci_url, 'CI run URL to record on the evidence'
59
+ help_flag :pr, "Mark this as a PR's CI: passing tests recorded as pr_passing, no merged evidence demoted"
60
+ help_flag :junit, 'JUnit XML report (repeatable to combine a surface split across shards)'
61
+ help_flag :base_dir, "Where the report's relative test-file paths resolve from (e.g. frontend)"
62
+ help_flag :repo, 'Repository the tests live in (org/repo)'
63
+ help_flag :sha, 'Checkout commit SHA'
64
+ help_flag :allow_empty, 'Do not error when a report resolves no fp:<slug> markers'
65
+ help_example 'fp ci-report --junit junit.xml --project stowzilla --surface api --ci-url "$CI_RUN_URL"'
66
+ help_example 'fp ci-report --junit web.xml --surface web --base-dir frontend --pr "$PR_URL"'
67
+ help_note "This is CI's command, not an agent's: only it writes passing/failing. Run it on both\n" \
68
+ 'green and red builds (if: always()) so failures show on the matrix.'
69
+
53
70
  KNOWN_FLAGS = {
54
71
  project: :string,
55
72
  surface: :string,