featureparity 0.0.24 → 0.0.26

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: 2403a7e55f95df5cf1821375442902c0a06784c26186f1e2fc1ae2e474ee937b
4
+ data.tar.gz: 4cbb3354c710f6d6e19114b2e2c28e533251c10bfa4a93c0a917ff73e5ebce93
5
5
  SHA512:
6
- metadata.gz: 55bde2507b4cfccbe49b8d799d0b3e7efba22c0298450e823a3ac86299ec4d392b0e51f13c3c643a2abc1d60f1d68d091074420c0928fbbcf9da6947fea296d1
7
- data.tar.gz: c5d9754313a59721bfc78f5d4e65d4924ef1f5120659037cace19e6ee816a96dea0e467f47ad285cf03003ba37ed836501754c5592f9b22385f5a52315be5cae
6
+ metadata.gz: 8a065b839f21e0bdf4194f65b93c233abf3e09ef11a50f96d0259d68609fc1c6e89e3d07e760a5a8d3b3bb12b849b7fdb7ea1c3a5fa65fc59b4befa053595366
7
+ data.tar.gz: 50e977bdf9a66c6f24a8a264d9334a506d5f15379f8e9aeaa8635d6cf84b5f9eee526e990c6e9b1a041631f1e15b7115e3ed3484311130f1fd23ca0ad7d35cde
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, sha: nil)
285
285
  body = {
286
286
  ci_url: ci_url,
287
287
  surface: surface,
@@ -289,6 +289,9 @@ module Fp
289
289
  }
290
290
  # Only send pr_url when set, so a merged-branch run stays a plain ingest.
291
291
  body[:pr_url] = pr_url if pr_url && !pr_url.empty?
292
+ # The checkout commit, so the receipt (or a PR run's branch verdict) links the
293
+ # exact revision the suite ran at.
294
+ body[:sha] = sha if sha && !sha.empty?
292
295
  body[:results] = results if results && !results.empty?
293
296
  # Testcase-level source receipts stay separate from aggregate Evidence outcomes:
294
297
  # several tests may back one cell, and each can change independently.
@@ -296,6 +299,10 @@ module Fp
296
299
  # Loose ends: failing tests with no fp:<slug> marker. The server stores them so the
297
300
  # matrix can surface a red suite whose failures aren't bound to any requirement.
298
301
  body[:unmarked_failures] = unmarked_failures if unmarked_failures && !unmarked_failures.empty?
302
+ # Orphan markers: fp:<slug> markers that bind to no requirement. Sent whenever the
303
+ # run could tell (an empty array included), so the server replaces the default
304
+ # branch's recorded set and a fixed marker drops off; nil means "couldn't tell".
305
+ body[:orphan_markers] = orphan_markers unless orphan_markers.nil?
299
306
 
300
307
  post("/api/projects/#{project_id}/ci_results", body)
301
308
  end
@@ -349,13 +356,21 @@ module Fp
349
356
  # `results` are the run's outcomes already bound to their markers locally (see
350
357
  # ingest_ci_results). Sending them is what lets a surface whose repo isn't registered
351
358
  # — 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)
359
+ def preview_parity(project_id, surface:, junit_xmls:, level: nil, results: nil,
360
+ pull_request: false, pr_url: nil)
353
361
  # Older API deployments read `surface_filter` for report scoping while `surface`
354
362
  # identifies the run's overlay. Send both during the rollout; current servers use
355
363
  # `surface` for both meanings.
356
364
  body = { surface: surface, surface_filter: surface, junit_xmls: Array(junit_xmls) }
357
365
  body[:results] = results if results && !results.empty?
358
366
  body[:level] = level if level
367
+ # A pull request's run is judged on what it changes, not on what the default branch
368
+ # already owes. Only sent when asked for, so a default-branch run stays the
369
+ # standing verdict.
370
+ if pull_request
371
+ body[:pull_request] = true
372
+ body[:pr_url] = pr_url if pr_url && !pr_url.to_s.empty?
373
+ end
359
374
  post("/api/projects/#{project_id}/parity/preview", body)
360
375
  end
361
376
 
@@ -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
 
@@ -140,9 +146,13 @@ module Fp
140
146
  # with servers old enough to ignore client-resolved results.
141
147
  junit_xml: resolved_reports.first[:xml],
142
148
  pr_url: opts[:pr],
149
+ sha: commit_sha,
143
150
  results: combined_results,
144
151
  test_revisions: combined_revisions,
145
- unmarked_failures: totals[:unmarked_failures]
152
+ unmarked_failures: totals[:unmarked_failures],
153
+ # nil when the requirement list couldn't be fetched (known.nil?) — then nothing
154
+ # was partitioned and we must not tell the server "no orphans this run".
155
+ orphan_markers: known.nil? ? nil : totals[:orphan_markers].uniq
146
156
  )
147
157
 
148
158
  unless result[:ok]
@@ -261,11 +271,12 @@ module Fp
261
271
  end.values
262
272
  end
263
273
 
274
+ # Split results into those whose slug names a requirement and the orphans. Returns
275
+ # the orphan *entries* (not just slugs) so the run can report where each was found.
264
276
  def partition_known(results, known)
265
277
  return [results, []] if known.nil?
266
278
 
267
- kept, unknown = results.partition { |r| known.include?(r[:slug]) }
268
- [kept, unknown.map { |r| r[:slug] }.uniq]
279
+ results.partition { |r| known.include?(r[:slug]) }
269
280
  end
270
281
 
271
282
  # 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:
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'open3'
4
+ require 'set'
3
5
  require_relative 'attach'
4
6
 
5
7
  module Fp
@@ -37,11 +39,14 @@ module Fp
37
39
  help_flag :junit, 'JUnit XML report to batch-upload evidence from'
38
40
  help_flag :base_dir, 'Where relative test-file paths in the report resolve from'
39
41
  help_flag :attach_media, 'With --junit: upload the videos/screenshots the run recorded ([[ATTACHMENT|…]]) as visual evidence'
42
+ help_flag :stamp_all, 'With --junit: put --pr/--work-item on every receipt, not only on tests in files this branch changed'
40
43
  help_example 'fp report print_container_qr --project stowzilla --surface api --file spec/qr_spec.rb --repo stowzilla/app'
41
44
  help_example 'fp report --junit junit.xml --project stowzilla --surface api --repo stowzilla/app',
42
45
  'Batch-upload from a JUnit report (slugs come from fp:<slug> markers)'
43
46
  help_note "Agents report 'present' or 'stub' only. CI writes 'passing'/'failing' via fp ci-report.\n" \
44
- 'Always pass --test-type when a surface has more than one suite, or they overwrite each other.'
47
+ "Always pass --test-type when a surface has more than one suite, or they overwrite each other.\n" \
48
+ 'With --junit, --pr and --work-item land only on tests in files this branch changed (git); ' \
49
+ 'the rest of the suite keeps the card and PR that introduced it.'
45
50
 
46
51
  KNOWN_FLAGS = {
47
52
  project: :string,
@@ -58,7 +63,8 @@ module Fp
58
63
  state: :string,
59
64
  junit: :string,
60
65
  base_dir: :string,
61
- attach_media: :boolean
66
+ attach_media: :boolean,
67
+ stamp_all: :boolean
62
68
  }.freeze
63
69
 
64
70
  VALID_STATES = %w[present stub].freeze
@@ -261,6 +267,12 @@ module Fp
261
267
 
262
268
  project_id = resolve_project_id(opts[:project])
263
269
 
270
+ # --pr and --work-item say what prompted *this* work. A suite run covers every
271
+ # marker in the repo, so stamping them on every receipt rewrote the card and PR
272
+ # on requirements the branch never touched — "View Card" on an unrelated
273
+ # requirement led to whichever card last ran the suite (Fizzy #1631). Only the
274
+ # tests in files this branch changed get them; the rest keep their own.
275
+ provenance = provenance_scope(opts, base_dir)
264
276
  reported = []
265
277
  skipped_unknown = []
266
278
  api_unavailable = false
@@ -289,9 +301,10 @@ module Fp
289
301
  }
290
302
  params[:test_type] = binding.test_type if binding.test_type
291
303
  params[:line] = binding.line if binding.line
292
- params[:pr_url] = opts[:pr] if opts[:pr]
304
+ stamped = provenance.stamps?(binding.file)
305
+ params[:pr_url] = opts[:pr] if opts[:pr] && stamped
293
306
  params[:sha] = opts[:sha] if opts[:sha]
294
- params[:work_item_url] = opts[:work_item] if opts[:work_item]
307
+ params[:work_item_url] = opts[:work_item] if opts[:work_item] && stamped
295
308
  params[:ci_url] = opts[:ci_url] if opts[:ci_url]
296
309
  params[:title] = binding.title if binding.title
297
310
 
@@ -310,7 +323,7 @@ module Fp
310
323
  exit 1
311
324
  end
312
325
 
313
- reported << { binding: binding, state: state, recorded: true,
326
+ reported << { binding: binding, state: state, recorded: true, stamped: stamped,
314
327
  stored_status: stored_status_from(report_result),
315
328
  stored_source: stored_source_from(report_result) }
316
329
  end
@@ -321,7 +334,80 @@ module Fp
321
334
  end
322
335
 
323
336
  emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable, collisions,
324
- empty_name: empty_name, media: media, uploaded_media: uploaded_media)
337
+ empty_name: empty_name, media: media, uploaded_media: uploaded_media,
338
+ provenance: provenance)
339
+ end
340
+
341
+ # Which receipts of a --junit batch carry --pr/--work-item.
342
+ #
343
+ # `files` is the set of repo-relative paths this branch changed — the commits since
344
+ # it left the default branch, plus anything uncommitted or untracked — or, when
345
+ # the checkout *is* the default branch, the files in its HEAD commit. Nil means
346
+ # git couldn't say (not a repository, no default branch to compare with), and then
347
+ # every receipt is stamped as before, with a note. `--stamp-all` forces that.
348
+ ProvenanceScope = Struct.new(:mode, :files, keyword_init: true) do
349
+ def stamps?(file)
350
+ return true if mode == :all || files.nil?
351
+
352
+ files.include?(file.to_s)
353
+ end
354
+
355
+ def scoped?
356
+ mode != :all && !files.nil?
357
+ end
358
+ end
359
+
360
+ def provenance_scope(opts, base_dir)
361
+ return ProvenanceScope.new(mode: :all, files: nil) if opts[:stamp_all]
362
+ return ProvenanceScope.new(mode: :all, files: nil) if opts[:pr].nil? && opts[:work_item].nil?
363
+
364
+ root = Fp::JUnit.repo_root_for(base_dir)
365
+ return ProvenanceScope.new(mode: :no_git, files: nil) if root.nil?
366
+
367
+ head = git_out(root, 'rev-parse', 'HEAD')
368
+ return ProvenanceScope.new(mode: :no_git, files: nil) if head.nil? || head.empty?
369
+
370
+ base = merge_base_with_default_branch(root)
371
+ committed =
372
+ if base && base != head
373
+ git_out(root, 'diff', '--name-only', base, 'HEAD')
374
+ elsif base
375
+ # On the default branch itself: the work is whatever this commit touched.
376
+ git_out(root, 'diff-tree', '--no-commit-id', '--name-only', '-r', '--root', 'HEAD')
377
+ end
378
+ return ProvenanceScope.new(mode: :no_git, files: nil) if committed.nil?
379
+
380
+ pending = git_out(root, 'diff', '--name-only', 'HEAD') || ''
381
+ untracked = git_out(root, 'ls-files', '--others', '--exclude-standard') || ''
382
+ files = [committed, pending, untracked].join("\n").split("\n").map(&:strip).reject(&:empty?).to_set
383
+ ProvenanceScope.new(mode: (base != head ? :branch : :commit), files: files)
384
+ end
385
+
386
+ # The merge base between HEAD and the default branch, trying origin's HEAD first
387
+ # and the usual names after it. Nil when none of them exists locally.
388
+ def merge_base_with_default_branch(root)
389
+ candidates = []
390
+ remote_head = git_out(root, 'symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD')
391
+ candidates << remote_head if remote_head && !remote_head.empty?
392
+ candidates.concat(%w[origin/main origin/master main master])
393
+ candidates.uniq.each do |ref|
394
+ verified = git_out(root, 'rev-parse', '--verify', '-q', "#{ref}^{commit}")
395
+ next if verified.nil? || verified.empty?
396
+
397
+ base = git_out(root, 'merge-base', ref, 'HEAD')
398
+ return base if base && !base.empty?
399
+ end
400
+ nil
401
+ end
402
+
403
+ # Stdout of a git command, stripped — '' when it printed nothing, nil on failure.
404
+ def git_out(root, *args)
405
+ stdout, _stderr, status = Open3.capture3('git', *args, chdir: root)
406
+ return nil unless status.success?
407
+
408
+ stdout.strip
409
+ rescue SystemCallError
410
+ nil
325
411
  end
326
412
 
327
413
  # Upload the media (videos/screenshots) a run recorded for its bound testcases as
@@ -397,10 +483,13 @@ module Fp
397
483
  end
398
484
 
399
485
  def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable, collisions = [],
400
- empty_name: [], media: [], uploaded_media: [])
486
+ empty_name: [], media: [], uploaded_media: [], provenance: nil)
487
+ provenance ||= ProvenanceScope.new(mode: :all, files: nil)
488
+ stamping = !opts[:pr].nil? || !opts[:work_item].nil?
401
489
  data = {
402
490
  repo: opts[:repo],
403
- reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, test_type: r[:binding].test_type, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded], stored_status: r[:stored_status], superseded_by_ci: ci_superseded?(r[:state], r[:stored_status], r[:stored_source]) } },
491
+ reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, test_type: r[:binding].test_type, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded], stamped: (stamping ? r[:stamped] != false : nil), stored_status: r[:stored_status], superseded_by_ci: ci_superseded?(r[:state], r[:stored_status], r[:stored_source]) }.compact },
492
+ provenance: (stamping ? { scoped: provenance.scoped?, mode: provenance.mode.to_s, stamped: reported.count { |r| r[:stamped] != false } } : nil),
404
493
  skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
405
494
  unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } },
406
495
  empty_name: empty_name,
@@ -423,7 +512,21 @@ module Fp
423
512
  loc = r[:binding].line ? "#{r[:binding].file}:#{r[:binding].line}" : r[:binding].file
424
513
  type = r[:binding].test_type ? " [#{r[:binding].test_type}]" : ''
425
514
  shown_state = superseded ? "#{r[:stored_status]}, kept CI — #{r[:state]} ignored" : r[:state]
426
- puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface}#{type} (#{shown_state}) #{loc}"
515
+ stamp = stamping && provenance.scoped? && r[:stamped] ? ' ← this change' : ''
516
+ puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface}#{type} (#{shown_state}) #{loc}#{stamp}"
517
+ end
518
+
519
+ if stamping
520
+ puts
521
+ stamped = reported.count { |r| r[:stamped] != false }
522
+ what = [opts[:pr] && '--pr', opts[:work_item] && '--work-item'].compact.join(' and ')
523
+ if provenance.scoped?
524
+ where = provenance.mode == :commit ? 'the files in this commit' : 'the files this branch changed'
525
+ puts "ℹ️ #{what} recorded on #{stamped} of #{reported.length} receipt(s) — the tests in #{where}."
526
+ puts ' The rest keep the card and PR that introduced them. Pass --stamp-all to override.'
527
+ elsif provenance.mode == :no_git
528
+ puts "ℹ️ #{what} recorded on every receipt: git could not say which files this change touched."
529
+ end
427
530
  end
428
531
 
429
532
  emit_collision_warning(collisions)
@@ -158,6 +158,21 @@ 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
167
+ # An open pull request's CI has reported on this test since the default
168
+ # branch did. It never changes the status above (Fizzy #1631); it is
169
+ # the heads-up that a branch says otherwise, with the PR to look at.
170
+ if e['branch_status']
171
+ branch = ["On PR: #{e['branch_status']}"]
172
+ branch << e['branch_pr_url'] if e['branch_pr_url']
173
+ branch << '(not merged)'
174
+ puts " #{branch.join(' ')}"
175
+ end
161
176
  puts " Type: #{e['test_type']}" if e['test_type'] && e['test_type'] != 'unspecified'
162
177
  if e['source_file']
163
178
  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.26'
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.26
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-27 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http