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 +4 -4
- data/lib/fp/client.rb +17 -2
- data/lib/fp/commands/check.rb +190 -18
- data/lib/fp/commands/ci_report.rb +18 -7
- data/lib/fp/commands/init.rb +7 -0
- data/lib/fp/commands/report.rb +112 -9
- data/lib/fp/commands/show.rb +15 -0
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +47 -0
- data/skills/fp/references/cli.md +43 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2403a7e55f95df5cf1821375442902c0a06784c26186f1e2fc1ae2e474ee937b
|
|
4
|
+
data.tar.gz: 4cbb3354c710f6d6e19114b2e2c28e533251c10bfa4a93c0a917ff73e5ebce93
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
data/lib/fp/commands/check.rb
CHANGED
|
@@ -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',
|
|
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['
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
return if
|
|
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
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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,
|
|
118
|
-
test_revisions,
|
|
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(
|
|
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
|
-
|
|
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,
|
data/lib/fp/commands/init.rb
CHANGED
|
@@ -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:
|
data/lib/fp/commands/report.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -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
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
|
data/skills/fp/references/cli.md
CHANGED
|
@@ -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.
|
|
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-
|
|
11
|
+
date: 2026-10-27 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: net-http
|