featureparity 0.0.23 → 0.0.25

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: 3fd282368d3fb2417adaf990518352726bc46fe816d70e73ec04f57e2a5ea067
4
- data.tar.gz: 7edf6bda3e44e4b8cce99efbe59d1adff0cbfe9ab2b8c24613a4b0eaf456cb7f
3
+ metadata.gz: a7a4558cdeaa90d0b4197daca351b9eadbeee5af21cb8454e9a1f44b3725bbaa
4
+ data.tar.gz: d0134aeca43dd6ee3415bfafe25ea7f67212eda1b8577a3bdfd9d9a3800aa150
5
5
  SHA512:
6
- metadata.gz: 83b7560fa29083b77581005c176b3c01f9a2d60201616cbb97dc8b60ede719f8029b4a02b7d5012310a3a57ef6cd642bd21af32344c5e42c8c300ecb76617356
7
- data.tar.gz: 1d2b3bf3e6500b1bed84696588225ad718f3aaa9016ffbeffc410226c631c8e55fb48cdafa30446f41814ac2869eb166b98e10fbb21d00b7fb8c2a4d73731802
6
+ metadata.gz: 70827aadfd85037c992e670923ae4c7065c6ba913905028b903f4b6535f8498f530d3ec51a9ff245aa9734a3f044d6ca779b5e8b849b0a15d1b6b2aaed360e2f
7
+ data.tar.gz: 9ba46d5f8efa5a57e4b23c2557969cdd5c6b6697cb8584bb98b7c11fc237a099743c33474b46d05444361785278707981206af09e7c32570261ae5b104a9885d
data/lib/fp/client.rb CHANGED
@@ -108,16 +108,17 @@ module Fp
108
108
  end
109
109
 
110
110
  # POST /projects/:id/showroom/media/presign
111
- def presign_showroom_media(project_id, filename:, content_type:, size_bytes:, surface_id: nil)
111
+ def presign_showroom_media(project_id, filename:, content_type:, size_bytes:, surface_id: nil, purpose: nil)
112
112
  body = { filename: filename, content_type: content_type, size_bytes: size_bytes }
113
113
  body[:surface_id] = surface_id if surface_id
114
+ body[:purpose] = purpose if purpose
114
115
  post("/api/projects/#{project_id}/showroom/media/presign", body)
115
116
  end
116
117
 
117
118
  # POST /projects/:id/showroom/media
118
119
  # Confirm an upload and create the media record.
119
120
  def create_showroom_media(project_id, media_id:, s3_key:, filename:, content_type:, size_bytes:,
120
- surface_id: nil, caption: nil, position: nil)
121
+ surface_id: nil, caption: nil, position: nil, purpose: nil)
121
122
  body = {
122
123
  media_id: media_id,
123
124
  s3_key: s3_key,
@@ -128,6 +129,7 @@ module Fp
128
129
  body[:surface_id] = surface_id if surface_id
129
130
  body[:caption] = caption if caption
130
131
  body[:position] = position if position
132
+ body[:purpose] = purpose if purpose
131
133
  post("/api/projects/#{project_id}/showroom/media", body)
132
134
  end
133
135
 
@@ -279,7 +281,7 @@ module Fp
279
281
  # only do that by fetching the test files from GitHub, which fails silently on a
280
282
  # private repo, so resolved results are always sent when we have them.
281
283
  def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil, results: nil,
282
- test_revisions: nil, unmarked_failures: nil)
284
+ test_revisions: nil, unmarked_failures: nil, orphan_markers: nil)
283
285
  body = {
284
286
  ci_url: ci_url,
285
287
  surface: surface,
@@ -294,6 +296,10 @@ module Fp
294
296
  # Loose ends: failing tests with no fp:<slug> marker. The server stores them so the
295
297
  # matrix can surface a red suite whose failures aren't bound to any requirement.
296
298
  body[:unmarked_failures] = unmarked_failures if unmarked_failures && !unmarked_failures.empty?
299
+ # Orphan markers: fp:<slug> markers that bind to no requirement. Sent whenever the
300
+ # run could tell (an empty array included), so the server replaces the default
301
+ # branch's recorded set and a fixed marker drops off; nil means "couldn't tell".
302
+ body[:orphan_markers] = orphan_markers unless orphan_markers.nil?
297
303
 
298
304
  post("/api/projects/#{project_id}/ci_results", body)
299
305
  end
@@ -347,13 +353,21 @@ module Fp
347
353
  # `results` are the run's outcomes already bound to their markers locally (see
348
354
  # ingest_ci_results). Sending them is what lets a surface whose repo isn't registered
349
355
  # — or a private one the server can't read — be previewed at all.
350
- def preview_parity(project_id, surface:, junit_xmls:, level: nil, results: nil)
356
+ def preview_parity(project_id, surface:, junit_xmls:, level: nil, results: nil,
357
+ pull_request: false, pr_url: nil)
351
358
  # Older API deployments read `surface_filter` for report scoping while `surface`
352
359
  # identifies the run's overlay. Send both during the rollout; current servers use
353
360
  # `surface` for both meanings.
354
361
  body = { surface: surface, surface_filter: surface, junit_xmls: Array(junit_xmls) }
355
362
  body[:results] = results if results && !results.empty?
356
363
  body[:level] = level if level
364
+ # A pull request's run is judged on what it changes, not on what the default branch
365
+ # already owes. Only sent when asked for, so a default-branch run stays the
366
+ # standing verdict.
367
+ if pull_request
368
+ body[:pull_request] = true
369
+ body[:pr_url] = pr_url if pr_url && !pr_url.to_s.empty?
370
+ end
357
371
  post("/api/projects/#{project_id}/parity/preview", body)
358
372
  end
359
373
 
@@ -35,18 +35,30 @@ module Fp
35
35
  # that isn't clean yet — you get the annotations without blocking every merge on day
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
+ #
39
+ # `--pr [url]` (with --junit) asks the pull-request question instead of the standing
40
+ # one: "does this change make parity worse?" The server judges the run against the
41
+ # merged baseline and fails only on what the run introduced — a bound test gone red, a
42
+ # bound test that vanished, a non-green test added where there was none. Whatever
43
+ # master already owed is listed as inherited and not counted, so a PR that did
44
+ # everything right isn't red because a removal proposal is still waiting on a human.
45
+ # The default-branch gate (no --pr) keeps asking the standing question and stays red
46
+ # until someone decides — that is where that signal belongs (Fizzy #1506).
38
47
  class Check < Base
39
48
  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]'
49
+ help_usage 'fp check [--project <slug>] [--surface X] [--level L] [--junit FILE]... [--pr [URL]] [--warn-only]'
41
50
  help_flag :project, 'Project slug (falls back to .featureparity.yml)'
42
51
  help_flag :surface, 'Gate a single surface'
43
52
  help_flag :test_type, 'Gate a single test type'
44
53
  help_flag :level, 'Override the enforcement level for this run (off/regression/covered/strict)'
45
54
  help_flag :junit, "Gate the branch on this run's results, overlaid on the grid (repeatable, one per surface)"
46
55
  help_flag :base_dir, "Where the report's relative test-file paths resolve from"
56
+ help_flag :pr, 'With --junit: this run is a pull request — fail only on parity it makes worse, ' \
57
+ 'not on what the default branch already owes (optionally the PR URL)'
47
58
  help_flag :warn_only, 'Report violations but still exit 0'
48
59
  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"
60
+ help_example 'fp check --surface api --junit junit.xml', 'Gate a default-branch run on its own results'
61
+ help_example 'fp check --surface api --junit junit.xml --pr', 'Gate a pull request on what it changes'
50
62
  help_example 'fp check --warn-only --json'
51
63
  help_note "Exit 0 = passed, 1 = violations (or request failed). The level lives on the project\n" \
52
64
  'server-side; --level only overrides this one run.'
@@ -58,9 +70,17 @@ module Fp
58
70
  level: :string,
59
71
  junit: :string,
60
72
  base_dir: :string,
73
+ # `--pr` alone is true; `--pr <url>` carries the URL. Not :boolean on purpose, so
74
+ # the parser takes a following value when one is given.
75
+ pr: :string,
61
76
  warn_only: :boolean
62
77
  }.freeze
63
78
 
79
+ # How many inherited violations to list before summarising the rest. They are
80
+ # informational — the gate already said they don't count — and on a project with a
81
+ # long standing backlog a full table buries the regressions that do.
82
+ INHERITED_PREVIEW_ROWS = 10
83
+
64
84
  def run(args)
65
85
  # Collect every --junit occurrence (parse_flags keeps only the last of a repeated
66
86
  # flag), matching `fp ci-report`.
@@ -73,6 +93,13 @@ module Fp
73
93
  surface = opts[:surface] || project_config.surface
74
94
  level = opts[:level] || project_config.enforcement
75
95
 
96
+ # The pull-request question needs a run to compare: without --junit there is
97
+ # nothing to judge against the baseline.
98
+ if opts[:pr] && junit_paths.empty?
99
+ output.error('--pr needs --junit: a pull request is judged on its own run\'s results.')
100
+ exit 1
101
+ end
102
+
76
103
  project_id = resolve_project_id(project_slug)
77
104
 
78
105
  result =
@@ -91,6 +118,13 @@ module Fp
91
118
  violations = report['violations'] || []
92
119
  passed = report['passing']
93
120
 
121
+ # An API that predates --pr ignores the flag and answers the standing question.
122
+ # Say so rather than let a reader believe the verdict was scoped to the change.
123
+ if opts[:pr] && !report['pull_request']
124
+ warn 'Warning: the API did not apply --pr (it predates pull-request mode); ' \
125
+ 'this verdict counts every violation, including those the default branch already has.'
126
+ end
127
+
94
128
  render(report, violations, passed, project_slug, opts)
95
129
 
96
130
  # A failing gate is the whole point of the command, so it must be the exit code
@@ -130,7 +164,10 @@ module Fp
130
164
  surface: surface,
131
165
  junit_xmls: junit_paths.map { |p| File.read(p) },
132
166
  level: level,
133
- results: resolve_results(junit_paths, surface, opts)
167
+ results: resolve_results(junit_paths, surface, opts),
168
+ pull_request: !!opts[:pr],
169
+ # `--pr` alone parses as true; `--pr <url>` as the URL.
170
+ pr_url: (opts[:pr].is_a?(String) ? opts[:pr] : nil)
134
171
  )
135
172
  end
136
173
 
@@ -179,7 +216,13 @@ module Fp
179
216
  scope = report['surface'] ? " (surface: #{report['surface']})" : ''
180
217
  puts "FeatureParity gate: #{project_slug}#{scope}"
181
218
  puts "Enforcement level: #{report['level']}"
182
- if report['preview']
219
+ if report['pull_request']
220
+ reported = Array(report['preview_surfaces']).join(', ')
221
+ puts "Mode: pull request — judging this run's results#{reported.empty? ? '' : " for #{reported}"} " \
222
+ 'against what the default branch has recorded (nothing saved)'
223
+ puts ' Only parity this change makes worse fails the gate; what the default branch ' \
224
+ 'already owes is listed, not counted.'
225
+ elsif report['preview']
183
226
  reported = Array(report['preview_surfaces']).join(', ')
184
227
  puts "Mode: preview — evaluating this run's results#{reported.empty? ? '' : " for #{reported}"} (nothing saved)"
185
228
  end
@@ -198,10 +241,14 @@ module Fp
198
241
 
199
242
  print_state_counts(summary)
200
243
  print_scope_notice(report)
201
- print_violations(violations)
244
+ print_violations(violations, report)
245
+ print_improvements(report)
246
+ print_unmeasured(report)
247
+ print_inherited(report)
202
248
  print_orphans(report)
203
249
  print_pending_reviews(report, project_slug)
204
250
  print_unscoped_warning(summary, project_slug)
251
+ print_next_steps(report, violations)
205
252
  print_verdict(passed, violations, opts, report)
206
253
  end
207
254
  end
@@ -252,10 +299,98 @@ module Fp
252
299
  puts
253
300
  end
254
301
 
255
- def print_violations(violations)
302
+ def print_violations(violations, report = {})
256
303
  return if violations.empty?
257
304
 
258
- puts "#{violations.size} violation(s):"
305
+ if report['pull_request']
306
+ puts "#{violations.size} regression(s) introduced by this change:"
307
+ else
308
+ puts "#{violations.size} violation(s):"
309
+ end
310
+ violations_table(violations)
311
+ puts
312
+ end
313
+
314
+ # Pull-request mode: what the default branch already owed. Listed so a reviewer can
315
+ # see why the standing gate is red and that this change didn't cause it; never
316
+ # counted, so never the reason this check fails. Capped — the regressions above are
317
+ # what the reader needs to act on, and a long backlog shouldn't bury them.
318
+ def print_inherited(report)
319
+ return unless report['pull_request']
320
+
321
+ inherited = report['inherited_violations'] || []
322
+ return if inherited.empty?
323
+
324
+ puts "#{inherited.size} violation(s) already on the default branch (not counted against this change):"
325
+ violations_table(inherited.first(INHERITED_PREVIEW_ROWS))
326
+ if inherited.size > INHERITED_PREVIEW_ROWS
327
+ puts " … and #{inherited.size - INHERITED_PREVIEW_ROWS} more. " \
328
+ 'Run fp check without --junit to see the standing verdict in full.'
329
+ end
330
+ puts
331
+ end
332
+
333
+ # Pull-request mode: the cells this run fixed. The positive half of the verdict —
334
+ # an agent checking its own branch needs to see that the requirement it set out to
335
+ # cover went green, not only that nothing broke.
336
+ def print_improvements(report)
337
+ return unless report['pull_request']
338
+
339
+ fixed = report['improvements'] || []
340
+ return if fixed.empty?
341
+
342
+ puts "#{fixed.size} cell(s) this change fixed:"
343
+ output.table(
344
+ %w[SLUG SURFACE WAS NOW],
345
+ fixed.map do |f|
346
+ [f['slug'], [f['surface'], presence(f['test_type'])].compact.join(' / '),
347
+ "#{Parity.glyph(f['from'])} #{f['from']}", "#{Parity.glyph(f['to'])} #{f['to']}"]
348
+ end
349
+ )
350
+ puts
351
+ end
352
+
353
+ # Results bound to requirements the gate doesn't measure yet — drafts awaiting a
354
+ # human's activation. Without this, an agent that proposed a requirement and wrote
355
+ # its test would hear nothing about the one thing its branch was for.
356
+ def print_unmeasured(report)
357
+ unmeasured = report['unmeasured'] || []
358
+ return if unmeasured.empty?
359
+
360
+ puts "#{unmeasured.size} result(s) bound to draft requirement(s) — not measured until a human activates them:"
361
+ output.table(
362
+ %w[SLUG SURFACE RESULT],
363
+ unmeasured.map { |u| [u['slug'], u['surface'], "#{Parity.glyph(u['status'])} #{u['status']}"] }
364
+ )
365
+ puts
366
+ end
367
+
368
+ # What to do next, grouped by who does it. The server names an action and an owner
369
+ # on every violation; this just reads them out so an agent asked to bring the
370
+ # project up to parity can take the `agent` rows and leave the `human` ones alone.
371
+ # On a pull request only the introduced violations are listed here — the inherited
372
+ # backlog is master's job, not this branch's.
373
+ def print_next_steps(report, violations)
374
+ rows = violations.select { |v| v['action'] }
375
+ rows += (report['orphans'] || []).reject { |o| o['inherited'] }.select { |o| o['action'] } if report['orphans_violate']
376
+ return if rows.empty?
377
+
378
+ puts 'Next steps:'
379
+ %w[agent human].each do |owner|
380
+ mine = rows.select { |v| v['owner'] == owner }
381
+ next if mine.empty?
382
+
383
+ puts " #{owner == 'agent' ? 'An agent can fix' : 'Waiting on a human'} (#{mine.size}):"
384
+ mine.group_by { |v| v['action'] }.each do |action, group|
385
+ slugs = group.map { |v| [v['slug'], v['surface']].compact.join('@') }.uniq
386
+ puts " #{action}: #{slugs.first(8).join(', ')}#{slugs.size > 8 ? ", … #{slugs.size - 8} more" : ''}"
387
+ puts " → #{group.first['action_hint']}" if group.first['action_hint']
388
+ end
389
+ end
390
+ puts
391
+ end
392
+
393
+ def violations_table(violations)
259
394
  # Show the TYPE column only when some requirement demands specific test types —
260
395
  # otherwise it's an empty column of dashes on every project that doesn't use the
261
396
  # feature.
@@ -275,7 +410,6 @@ module Fp
275
410
  end
276
411
  )
277
412
  end
278
- puts
279
413
  end
280
414
 
281
415
  # nil/blank → nil, so `presence(x)` reads as falsey in a guard.
@@ -293,20 +427,35 @@ module Fp
293
427
  # as a violation when it counts and a note when it doesn't. Only the --junit path
294
428
  # has a run to find these in; plain `fp check` reads recorded state and shows none.
295
429
  def print_orphans(report)
296
- orphans = report['orphans'] || []
297
- return if orphans.empty?
430
+ all = report['orphans'] || []
431
+ return if all.empty?
432
+
433
+ # On a pull request the default branch's own dangling markers are listed apart
434
+ # and never counted — this branch didn't add them.
435
+ inherited, orphans = all.partition { |o| o['inherited'] }
436
+
437
+ unless orphans.empty?
438
+ violates = report['orphans_violate']
439
+ lead = violates ? "#{orphans.size} orphan marker(s) — a violation at this level:" \
440
+ : "#{orphans.size} orphan marker(s) (not failing the gate at this level):"
441
+ puts lead
442
+ output.table(
443
+ %w[SLUG SURFACE WHERE],
444
+ orphans.map { |o| [o['slug'], o['surface'], marker_location(o)] }
445
+ )
446
+ puts 'These tests are marked fp:<slug> for a requirement that does not exist. ' \
447
+ 'Either propose it (fp propose --slug <slug> ...) or fix the marker\'s slug — ' \
448
+ 'FP will not invent a requirement from a marker.'
449
+ puts
450
+ end
298
451
 
299
- violates = report['orphans_violate']
300
- lead = violates ? "#{orphans.size} orphan marker(s) — a violation at this level:" \
301
- : "#{orphans.size} orphan marker(s) (not failing the gate at this level):"
302
- puts lead
452
+ return if inherited.empty?
453
+
454
+ puts "#{inherited.size} orphan marker(s) already on the default branch (not counted against this change):"
303
455
  output.table(
304
456
  %w[SLUG SURFACE WHERE],
305
- orphans.map { |o| [o['slug'], o['surface'], marker_location(o)] }
457
+ inherited.map { |o| [o['slug'], o['surface'], marker_location(o)] }
306
458
  )
307
- puts 'These tests are marked fp:<slug> for a requirement that does not exist. ' \
308
- 'Either propose it (fp propose --slug <slug> ...) or fix the marker\'s slug — ' \
309
- 'FP will not invent a requirement from a marker.'
310
459
  puts
311
460
  end
312
461
 
@@ -341,6 +490,29 @@ module Fp
341
490
  # reads "Gate passed" as "the whole project is covered" (Fizzy #1490).
342
491
  scope = report['scoped'] ? " for surface '#{report['surface']}' (other required surfaces not checked)" : ''
343
492
 
493
+ # A pull request's verdict is about the change, and the verdict line says so —
494
+ # "Gate passed" next to a red default branch would otherwise read as a
495
+ # contradiction. The baseline count tells the reviewer what master still owes.
496
+ if report['pull_request']
497
+ inherited = (report['inherited_violations'] || []).size
498
+ standing = inherited.positive? ? " #{inherited} violation(s) already on the default branch are not counted here." : ''
499
+ # Orphan markers the run added count too when the level says so; name them so a
500
+ # fail with no red cell doesn't read as "introduces 0 regressions".
501
+ added_orphans = report['orphans_violate'] ? (report['orphans'] || []).count { |o| !o['inherited'] } : 0
502
+ introduced = []
503
+ introduced << "#{violations.size} parity regression(s)" if violations.any? || added_orphans.zero?
504
+ introduced << "#{added_orphans} orphan marker(s)" if added_orphans.positive?
505
+ if passed
506
+ puts "Gate passed#{scope}: this change introduces no parity regressions.#{standing}"
507
+ elsif opts[:warn_only]
508
+ puts "Gate FAILED: this change introduces #{introduced.join(' and ')} — " \
509
+ 'exiting 0 because --warn-only was given.'
510
+ else
511
+ puts "Gate FAILED: this change introduces #{introduced.join(' and ')}.#{standing}"
512
+ end
513
+ return
514
+ end
515
+
344
516
  if passed
345
517
  puts "Gate passed#{scope}."
346
518
  elsif opts[:warn_only]
@@ -107,17 +107,23 @@ module Fp
107
107
  repo = opts[:repo] || presence(ENV['GITHUB_REPOSITORY'])
108
108
 
109
109
  totals = { created: 0, updated: 0, missing_failing: 0, reports: [], resolved: 0,
110
- unmatched: 0, unknown: [], unmarked_failures: [], test_revisions: 0,
110
+ unmatched: 0, unknown: [], orphan_markers: [], unmarked_failures: [], test_revisions: 0,
111
111
  findings_opened: 0, baselines_accepted: 0 }
112
112
 
113
113
  resolved_reports = junit_paths.map do |path|
114
114
  resolved = resolve_results(path, opts, commit_sha: commit_sha, repo: repo)
115
115
  totals[:unmatched] += resolved[:unmatched]
116
116
 
117
- results, unknown = partition_known(resolved[:results], known)
118
- test_revisions, revision_unknown = partition_known(resolved[:test_revisions], known)
117
+ results, orphans = partition_known(resolved[:results], known)
118
+ test_revisions, revision_orphans = partition_known(resolved[:test_revisions], known)
119
119
  totals[:resolved] += results.length
120
- totals[:unknown].concat(unknown).concat(revision_unknown)
120
+ totals[:unknown].concat(orphans.map { |r| r[:slug] }).concat(revision_orphans.map { |r| r[:slug] })
121
+ # The orphan markers themselves, with where they were found, for the server to
122
+ # record as the default branch's set (see Client#ingest_ci_results).
123
+ totals[:orphan_markers].concat(orphans.map do |r|
124
+ { slug: r[:slug], surface: r[:surface], name: r[:title],
125
+ source_file: r[:source_file], source_line: r[:source_line] }
126
+ end)
121
127
  totals[:unmarked_failures].concat(resolved[:unmarked_failures])
122
128
  totals[:test_revisions] += test_revisions.length
123
129
 
@@ -142,7 +148,10 @@ module Fp
142
148
  pr_url: opts[:pr],
143
149
  results: combined_results,
144
150
  test_revisions: combined_revisions,
145
- unmarked_failures: totals[:unmarked_failures]
151
+ unmarked_failures: totals[:unmarked_failures],
152
+ # nil when the requirement list couldn't be fetched (known.nil?) — then nothing
153
+ # was partitioned and we must not tell the server "no orphans this run".
154
+ orphan_markers: known.nil? ? nil : totals[:orphan_markers].uniq
146
155
  )
147
156
 
148
157
  unless result[:ok]
@@ -261,11 +270,12 @@ module Fp
261
270
  end.values
262
271
  end
263
272
 
273
+ # Split results into those whose slug names a requirement and the orphans. Returns
274
+ # the orphan *entries* (not just slugs) so the run can report where each was found.
264
275
  def partition_known(results, known)
265
276
  return [results, []] if known.nil?
266
277
 
267
- kept, unknown = results.partition { |r| known.include?(r[:slug]) }
268
- [kept, unknown.map { |r| r[:slug] }.uniq]
278
+ results.partition { |r| known.include?(r[:slug]) }
269
279
  end
270
280
 
271
281
  # Reports can be split into shards. Keep every independently identified testcase,
@@ -305,6 +305,13 @@ module Fp
305
305
  # had its fp: marker stripped: the remaining tests still pass, so a plain
306
306
  # suite stays green while the requirement loses its only proof. Use one
307
307
  # call per surface.
308
+ #
309
+ # On a pull request add --pr so the verdict is about the change: only
310
+ # parity the branch makes worse fails it, and what the default branch
311
+ # already owes is listed, not counted. A push to the default branch gets
312
+ # no --pr and the standing verdict:
313
+ #
314
+ # fp check --surface <surface-key> --junit junit.xml --pr "${{ github.event.pull_request.html_url }}"
308
315
  - name: Check parity
309
316
  if: env.HAVE_FP_KEY == 'true'
310
317
  env:
@@ -158,6 +158,12 @@ module Fp
158
158
  puts " #{surface}:"
159
159
  records.each do |e|
160
160
  puts " Status: #{e['status']}"
161
+ # A pull request's CI may have written over the merged verdict; show both
162
+ # when they differ so "failing" next to "PR: …" reads as the branch's
163
+ # result, not the default branch's.
164
+ if e['merged_status'] && e['merged_status'] != e['status']
165
+ puts " Merged: #{e['merged_status']} (the default branch's last verdict)"
166
+ end
161
167
  puts " Type: #{e['test_type']}" if e['test_type'] && e['test_type'] != 'unspecified'
162
168
  if e['source_file']
163
169
  loc = e['source_line'] ? "#{e['source_file']}:#{e['source_line']}" : e['source_file']
@@ -23,6 +23,7 @@ module Fp
23
23
  help_usage 'fp showroom --project <slug>',
24
24
  'fp showroom media --project <slug>',
25
25
  'fp showroom media add <file> --project <slug> [--surface <key>] [--caption "..."] [--position N]',
26
+ 'fp showroom media add <image> --project <slug> --surface <key> --icon',
26
27
  'fp showroom media edit <id> --project <slug> [--caption "..."] [--position N]',
27
28
  'fp showroom media remove <id> --project <slug> [--yes]'
28
29
  help_flag :project, 'Project slug (falls back to .featureparity.yml)'
@@ -33,6 +34,7 @@ module Fp
33
34
  help_example 'fp showroom --project stowzilla'
34
35
  help_example 'fp showroom media add e2e/checkout.webm --project stowzilla --surface web --caption "Checking out"'
35
36
  help_example 'fp showroom media add overview.png --project stowzilla --position 1'
37
+ help_example 'fp showroom media add app-icon.png --project stowzilla --surface ios --icon'
36
38
  help_example 'fp showroom media edit <id> --project stowzilla --caption ""'
37
39
  help_example 'fp showroom media remove <id> --project stowzilla --yes'
38
40
  help_note "Prints the welcome note, the gallery, and each surface's card, then the surfaces that are NOT shown and why.\n" \
@@ -7,6 +7,7 @@ module Fp
7
7
  module Commands
8
8
  # fp showroom media [--project <slug>] - list the showroom's media
9
9
  # fp showroom media add <file> [--surface <key>] [--caption] [--position N]
10
+ # fp showroom media add <image> --surface <key> --icon - set the surface's icon
10
11
  # fp showroom media edit <id> [--caption "..."] [--position N]
11
12
  # fp showroom media remove <id> [--yes]
12
13
  #
@@ -19,13 +20,19 @@ module Fp
19
20
  # Uploads reuse attach's flow (presign, PUT the bytes, confirm) and its visual file
20
21
  # types — images and videos only. SVG can carry script and PDFs don't render inline,
21
22
  # so both are refused before anything is sent.
23
+ #
24
+ # `--icon` (Fizzy #1499) makes the image the surface's own icon in place of the
25
+ # built-in platform logo: one per surface, a new one replaces the old, and removing
26
+ # it brings the built-in logo back.
22
27
  class ShowroomMedia < Base
23
28
  CONTENT_TYPES = Attach::CONTENT_TYPES.slice(*Attach::VISUAL_EXTENSIONS).freeze
24
29
  MAX_FILE_SIZE = Attach::MAX_FILE_SIZE
25
30
  MAX_CAPTION_LENGTH = 500
31
+ ICON_EXTENSIONS = %w[png jpg jpeg gif webp].freeze
32
+ MAX_ICON_SIZE = 5 * 1024 * 1024
26
33
 
27
34
  LIST_FLAGS = { project: :string }.freeze
28
- ADD_FLAGS = { project: :string, surface: :string, caption: :string, position: :string }.freeze
35
+ ADD_FLAGS = { project: :string, surface: :string, caption: :string, position: :string, icon: :boolean }.freeze
29
36
  EDIT_FLAGS = { project: :string, caption: :string, position: :string }.freeze
30
37
  REMOVE_FLAGS = { project: :string, yes: :boolean }.freeze
31
38
 
@@ -83,6 +90,7 @@ module Fp
83
90
 
84
91
  # Everything that can be checked locally is checked before a byte is sent.
85
92
  file = prepare_file!(path)
93
+ check_icon!(file, opts) if opts[:icon]
86
94
  caption = flag_text(opts[:caption]).strip if opts.key?(:caption)
87
95
  caption = nil if caption&.empty?
88
96
  check_caption!(caption) if caption
@@ -95,11 +103,12 @@ module Fp
95
103
  require_project!(opts)
96
104
  project_id = resolve_project_id(opts[:project])
97
105
  surface = opts[:surface] ? find_surface!(project_id, opts[:surface]) : nil
106
+ purpose = opts[:icon] ? 'icon' : nil
98
107
 
99
108
  presign = fetch!(client.presign_showroom_media(
100
109
  project_id,
101
110
  filename: file[:filename], content_type: file[:content_type],
102
- size_bytes: file[:size], surface_id: surface&.dig('id')
111
+ size_bytes: file[:size], surface_id: surface&.dig('id'), purpose: purpose
103
112
  ), prefix: 'Failed to get upload URL')['presign'] || {}
104
113
 
105
114
  uploaded = client.upload_to_presigned_url(presign['upload_url'], file[:path], file[:content_type])
@@ -109,13 +118,19 @@ module Fp
109
118
  project_id,
110
119
  media_id: presign['media_id'], s3_key: presign['s3_key'],
111
120
  filename: file[:filename], content_type: file[:content_type], size_bytes: file[:size],
112
- surface_id: surface&.dig('id'), caption: caption, position: position
121
+ surface_id: surface&.dig('id'), caption: caption, position: position, purpose: purpose
113
122
  ), prefix: 'Failed to confirm upload')['media'] || {}
114
123
 
115
124
  where = surface ? "surface '#{surface['key']}'" : 'the project gallery'
116
125
  output.success({ media: item, surface: surface&.dig('key'), project: opts[:project] }) do
117
- puts "Added #{file[:filename]} to #{where} in project '#{opts[:project]}'."
118
- print_item_detail(item)
126
+ if purpose
127
+ puts "Set #{file[:filename]} as the icon for #{where} in project '#{opts[:project]}'."
128
+ puts "Remove it to go back to the built-in logo: fp showroom media remove #{item['id']} " \
129
+ "--project #{opts[:project]}"
130
+ else
131
+ puts "Added #{file[:filename]} to #{where} in project '#{opts[:project]}'."
132
+ print_item_detail(item)
133
+ end
119
134
  end
120
135
  end
121
136
 
@@ -239,6 +254,22 @@ module Fp
239
254
  { path: path, filename: filename, content_type: content_type, size: size }
240
255
  end
241
256
 
257
+ # An icon is a still picture on one surface, with nothing to caption or order.
258
+ def check_icon!(file, opts)
259
+ problem =
260
+ if flag_text(opts[:surface]).strip.empty? then '--icon needs --surface <key>: an icon belongs to a surface.'
261
+ elsif opts.key?(:caption) || opts.key?(:position) then '--icon takes no --caption or --position.'
262
+ elsif !ICON_EXTENSIONS.include?(File.extname(file[:filename]).delete('.').downcase)
263
+ 'An icon must be an image (png, jpg, jpeg, gif, webp).'
264
+ elsif file[:size] > MAX_ICON_SIZE
265
+ "Icon too large: #{format_size(file[:size])} (max #{format_size(MAX_ICON_SIZE)})"
266
+ end
267
+ return unless problem
268
+
269
+ output.error(problem)
270
+ exit 1
271
+ end
272
+
242
273
  def check_caption!(caption)
243
274
  return if caption.length <= MAX_CAPTION_LENGTH
244
275
 
@@ -305,6 +336,7 @@ module Fp
305
336
 
306
337
  def print_item_line(item)
307
338
  position = item['position'].to_i.positive? ? "##{item['position'].to_i}" : '-'
339
+ position = 'icon' if item['purpose'] == 'icon'
308
340
  caption = item['caption'].to_s.strip
309
341
  line = " #{item['id']} #{position.ljust(3)} #{item['media_kind'].to_s.ljust(5)} #{item['filename']}"
310
342
  line += " \"#{caption}\"" unless caption.empty?
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.23'
4
+ VERSION = '0.0.25'
5
5
  end
data/skills/fp/SKILL.md CHANGED
@@ -336,6 +336,53 @@ This also catches the one regression a green test suite can't: a test that was d
336
336
  renamed, or had its `fp:` marker stripped. Every remaining test passes, so CI is happy,
337
337
  while the requirement quietly lost its only proof.
338
338
 
339
+ **On a pull request, add `--pr`.** A push to the default branch is asked "is the project
340
+ in parity?" — the standing verdict, red until every pending proposal is decided. A pull
341
+ request is asked "does this change make parity worse?": only what the run introduced (a
342
+ bound test gone red, one that vanished, a non-green test added) fails it, and what the
343
+ default branch already owed is listed as "already on the default branch" and not counted.
344
+ So a PR that proposes a removal and deletes the test is not red for a review that hasn't
345
+ happened yet — but one that deletes a test under a *modification* proposal is: a
346
+ modification asks for the test to be rewritten, not removed.
347
+
348
+ ```bash
349
+ fp check --surface api --junit junit.xml --pr "$PR_URL"
350
+ ```
351
+
352
+ **Verifying your own pull request.** Run the PR gate on your branch's JUnit and read three
353
+ things, all in `--json` too:
354
+
355
+ 1. `violations` — regressions you introduced. Must be empty. Each names an `action`.
356
+ 2. `improvements` — cells your change fixed (`from` → `to`). The requirement you set out
357
+ to cover should be here (or already `passing`).
358
+ 3. `unmeasured` — your results bound to **draft** requirements. A test for a requirement
359
+ you proposed lands here until a human activates it; that is expected, not a gap.
360
+
361
+ `inherited_violations` are the default branch's, not yours. Leave them unless you were
362
+ asked to clear them.
363
+
364
+ **Bringing the default branch up to parity.** When asked (or on a schedule), run the
365
+ standing gate and work the list the server hands back:
366
+
367
+ ```bash
368
+ fp check --json # violations[*].action / .owner / .action_hint
369
+ ```
370
+
371
+ Take every violation whose `owner` is `agent`, grouped by `action`:
372
+
373
+ | action | what to do |
374
+ |--------|------------|
375
+ | `write_test` | write a test on that surface, mark it `fp:<slug>`, `fp report` it |
376
+ | `fix_test` | fix the red test — or, if the feature was removed on purpose, `fp propose-change <slug> --removal` |
377
+ | `replace_stub` | replace the stub with a real test |
378
+ | `run_in_ci` | the marker exists but CI never ran it green: make sure the test runs in a CI job that calls `fp ci-report` for that surface |
379
+ | `reverify` | the wording changed with no proposal open: check the test still proves it, fix it if not, re-report to clear `suspect` |
380
+ | `fix_marker` | an orphan marker: fix the slug, or `fp propose` the requirement |
381
+
382
+ Leave `owner: human` rows alone — `await_review` is a proposal a person has to decide,
383
+ `await_merge` is a pull request a person has to merge — and say so in your report. Open one
384
+ pull request per coherent batch, gate it with `--pr`, and re-run `fp check` after it merges.
385
+
339
386
  ### 9. Fill in the client showroom when a surface is deployed
340
387
 
341
388
  The project's client (the person the software is being built for) sees one page: the
@@ -526,6 +526,7 @@ fp check --surface api --junit junit.xml # gate on this run's results (preview)
526
526
  | `--surface` | Gate one surface only |
527
527
  | `--level` | Override the project's enforcement level |
528
528
  | `--junit` | Evaluate this run's JUnit results without saving them (repeatable) |
529
+ | `--pr [url]` | With `--junit`: this run is a pull request — fail only on parity it makes worse |
529
530
  | `--warn-only` | Report violations but exit 0 |
530
531
 
531
532
  **Enforcement levels** (the level is configured on the project; don't hardcode one):
@@ -567,6 +568,48 @@ while the requirement silently lost its only proof. Pass one `--junit` per surfa
567
568
  `--surface` tells the server which cells the run had a chance to cover, and pooling
568
569
  surfaces into one call would misattribute vanished tests.
569
570
 
571
+ **Pull-request mode (`--pr`).** A push to the default branch asks "is the project in
572
+ parity?" — the standing verdict, red until every pending proposal is decided and every
573
+ gap filled. A pull request should be asked "does this change make parity worse?". With
574
+ `--pr` (and `--junit`) the run is judged against what the default branch has recorded and
575
+ only what it *introduced* fails: a bound test gone red, a bound test that vanished, a
576
+ non-green test added where there was none. What the default branch already owed is listed
577
+ as "already on the default branch" and not counted, so a PR that proposes a requirement's
578
+ removal and deletes its test is not red for the removal nobody has approved yet. Without
579
+ `--pr`, every PR on a `strict` project failed with master's own standing violations.
580
+
581
+ ```
582
+ 3 violation(s) already on the default branch (not counted against this change):
583
+ SLUG SURFACE STATE WHY
584
+ retire_old_heading api ⚠ suspect requirement changed; evidence needs re-verification
585
+ Gate passed: this change introduces no parity regressions. 3 violation(s) already on the default branch are not counted here.
586
+ ```
587
+
588
+ A deleted bound test is only excused by a *removal* proposal. Under a modification
589
+ proposal (or a human's direct edit) the evidence is `suspect`, and a `suspect` test
590
+ missing from the run is flagged like a passing one that vanished — the proposal asked
591
+ for the test to be rewritten, not removed.
592
+
593
+ The PR verdict also lists what the change *fixed* (cells violating on the default branch,
594
+ green with the run applied) and results bound to *draft* requirements the gate doesn't
595
+ measure until a human activates them. In `--json`: `improvements`, `unmeasured`. Orphan
596
+ markers the default branch's last run already recorded are listed as "already on the
597
+ default branch" and never fail the PR; only orphans the run added count.
598
+
599
+ **Next steps.** Every verdict ends with the violations that count, grouped by who acts.
600
+ Each violation in `--json` carries `action`, `owner` (`agent` | `human`) and `action_hint`:
601
+
602
+ | action | owner | means |
603
+ |--------|-------|-------|
604
+ | `write_test` | agent | `missing` — no evidence on this surface |
605
+ | `fix_test` | agent | `failing` — red, or the marker vanished; or propose removal |
606
+ | `replace_stub` | agent | `stub` — a stub stands where a real test should |
607
+ | `run_in_ci` | agent | `present` — the marker exists but CI has never run it green |
608
+ | `reverify` | agent | `suspect` with no proposal open — the wording changed; re-check and re-report |
609
+ | `await_review` | human | `suspect` under an open proposal |
610
+ | `await_merge` | human | `pr_passing` — green on an open pull request |
611
+ | `fix_marker` | agent | an orphan marker — fix the slug or propose the requirement |
612
+
570
613
  ### fp init
571
614
 
572
615
  Onboard a repo onto FeatureParity. Writes three files and makes no API calls (so it works
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.23
4
+ version: 0.0.25
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-21 00:00:00.000000000 Z
11
+ date: 2026-10-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http