featureparity 0.0.24 → 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: 4e68b70dce16d28e928ead4413623002ccda97f4508f62bbe6d980c8bb29a21c
4
- data.tar.gz: e630a9bd6f49db70c343f6374cd3c049a9c9fb4b786251dd313b76a177e0a1f9
3
+ metadata.gz: a7a4558cdeaa90d0b4197daca351b9eadbeee5af21cb8454e9a1f44b3725bbaa
4
+ data.tar.gz: d0134aeca43dd6ee3415bfafe25ea7f67212eda1b8577a3bdfd9d9a3800aa150
5
5
  SHA512:
6
- metadata.gz: 55bde2507b4cfccbe49b8d799d0b3e7efba22c0298450e823a3ac86299ec4d392b0e51f13c3c643a2abc1d60f1d68d091074420c0928fbbcf9da6947fea296d1
7
- data.tar.gz: c5d9754313a59721bfc78f5d4e65d4924ef1f5120659037cace19e6ee816a96dea0e467f47ad285cf03003ba37ed836501754c5592f9b22385f5a52315be5cae
6
+ metadata.gz: 70827aadfd85037c992e670923ae4c7065c6ba913905028b903f4b6535f8498f530d3ec51a9ff245aa9734a3f044d6ca779b5e8b849b0a15d1b6b2aaed360e2f
7
+ data.tar.gz: 9ba46d5f8efa5a57e4b23c2557969cdd5c6b6697cb8584bb98b7c11fc237a099743c33474b46d05444361785278707981206af09e7c32570261ae5b104a9885d
data/lib/fp/client.rb CHANGED
@@ -281,7 +281,7 @@ module Fp
281
281
  # only do that by fetching the test files from GitHub, which fails silently on a
282
282
  # private repo, so resolved results are always sent when we have them.
283
283
  def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil, results: nil,
284
- test_revisions: nil, unmarked_failures: nil)
284
+ test_revisions: nil, unmarked_failures: nil, orphan_markers: nil)
285
285
  body = {
286
286
  ci_url: ci_url,
287
287
  surface: surface,
@@ -296,6 +296,10 @@ module Fp
296
296
  # Loose ends: failing tests with no fp:<slug> marker. The server stores them so the
297
297
  # matrix can surface a red suite whose failures aren't bound to any requirement.
298
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?
299
303
 
300
304
  post("/api/projects/#{project_id}/ci_results", body)
301
305
  end
@@ -349,13 +353,21 @@ module Fp
349
353
  # `results` are the run's outcomes already bound to their markers locally (see
350
354
  # ingest_ci_results). Sending them is what lets a surface whose repo isn't registered
351
355
  # — or a private one the server can't read — be previewed at all.
352
- 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)
353
358
  # Older API deployments read `surface_filter` for report scoping while `surface`
354
359
  # identifies the run's overlay. Send both during the rollout; current servers use
355
360
  # `surface` for both meanings.
356
361
  body = { surface: surface, surface_filter: surface, junit_xmls: Array(junit_xmls) }
357
362
  body[:results] = results if results && !results.empty?
358
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
359
371
  post("/api/projects/#{project_id}/parity/preview", body)
360
372
  end
361
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']
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.24'
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.24
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