featureparity 0.0.23 → 0.0.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/fp/client.rb +18 -4
- data/lib/fp/commands/check.rb +190 -18
- data/lib/fp/commands/ci_report.rb +17 -7
- data/lib/fp/commands/init.rb +7 -0
- data/lib/fp/commands/show.rb +6 -0
- data/lib/fp/commands/showroom.rb +2 -0
- data/lib/fp/commands/showroom_media.rb +37 -5
- 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: a7a4558cdeaa90d0b4197daca351b9eadbeee5af21cb8454e9a1f44b3725bbaa
|
|
4
|
+
data.tar.gz: d0134aeca43dd6ee3415bfafe25ea7f67212eda1b8577a3bdfd9d9a3800aa150
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 70827aadfd85037c992e670923ae4c7065c6ba913905028b903f4b6535f8498f530d3ec51a9ff245aa9734a3f044d6ca779b5e8b849b0a15d1b6b2aaed360e2f
|
|
7
|
+
data.tar.gz: 9ba46d5f8efa5a57e4b23c2557969cdd5c6b6697cb8584bb98b7c11fc237a099743c33474b46d05444361785278707981206af09e7c32570261ae5b104a9885d
|
data/lib/fp/client.rb
CHANGED
|
@@ -108,16 +108,17 @@ module Fp
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
# POST /projects/:id/showroom/media/presign
|
|
111
|
-
def presign_showroom_media(project_id, filename:, content_type:, size_bytes:, surface_id: nil)
|
|
111
|
+
def presign_showroom_media(project_id, filename:, content_type:, size_bytes:, surface_id: nil, purpose: nil)
|
|
112
112
|
body = { filename: filename, content_type: content_type, size_bytes: size_bytes }
|
|
113
113
|
body[:surface_id] = surface_id if surface_id
|
|
114
|
+
body[:purpose] = purpose if purpose
|
|
114
115
|
post("/api/projects/#{project_id}/showroom/media/presign", body)
|
|
115
116
|
end
|
|
116
117
|
|
|
117
118
|
# POST /projects/:id/showroom/media
|
|
118
119
|
# Confirm an upload and create the media record.
|
|
119
120
|
def create_showroom_media(project_id, media_id:, s3_key:, filename:, content_type:, size_bytes:,
|
|
120
|
-
surface_id: nil, caption: nil, position: nil)
|
|
121
|
+
surface_id: nil, caption: nil, position: nil, purpose: nil)
|
|
121
122
|
body = {
|
|
122
123
|
media_id: media_id,
|
|
123
124
|
s3_key: s3_key,
|
|
@@ -128,6 +129,7 @@ module Fp
|
|
|
128
129
|
body[:surface_id] = surface_id if surface_id
|
|
129
130
|
body[:caption] = caption if caption
|
|
130
131
|
body[:position] = position if position
|
|
132
|
+
body[:purpose] = purpose if purpose
|
|
131
133
|
post("/api/projects/#{project_id}/showroom/media", body)
|
|
132
134
|
end
|
|
133
135
|
|
|
@@ -279,7 +281,7 @@ module Fp
|
|
|
279
281
|
# only do that by fetching the test files from GitHub, which fails silently on a
|
|
280
282
|
# private repo, so resolved results are always sent when we have them.
|
|
281
283
|
def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil, results: nil,
|
|
282
|
-
test_revisions: nil, unmarked_failures: nil)
|
|
284
|
+
test_revisions: nil, unmarked_failures: nil, orphan_markers: nil)
|
|
283
285
|
body = {
|
|
284
286
|
ci_url: ci_url,
|
|
285
287
|
surface: surface,
|
|
@@ -294,6 +296,10 @@ module Fp
|
|
|
294
296
|
# Loose ends: failing tests with no fp:<slug> marker. The server stores them so the
|
|
295
297
|
# matrix can surface a red suite whose failures aren't bound to any requirement.
|
|
296
298
|
body[:unmarked_failures] = unmarked_failures if unmarked_failures && !unmarked_failures.empty?
|
|
299
|
+
# Orphan markers: fp:<slug> markers that bind to no requirement. Sent whenever the
|
|
300
|
+
# run could tell (an empty array included), so the server replaces the default
|
|
301
|
+
# branch's recorded set and a fixed marker drops off; nil means "couldn't tell".
|
|
302
|
+
body[:orphan_markers] = orphan_markers unless orphan_markers.nil?
|
|
297
303
|
|
|
298
304
|
post("/api/projects/#{project_id}/ci_results", body)
|
|
299
305
|
end
|
|
@@ -347,13 +353,21 @@ module Fp
|
|
|
347
353
|
# `results` are the run's outcomes already bound to their markers locally (see
|
|
348
354
|
# ingest_ci_results). Sending them is what lets a surface whose repo isn't registered
|
|
349
355
|
# — or a private one the server can't read — be previewed at all.
|
|
350
|
-
def preview_parity(project_id, surface:, junit_xmls:, level: nil, results: nil
|
|
356
|
+
def preview_parity(project_id, surface:, junit_xmls:, level: nil, results: nil,
|
|
357
|
+
pull_request: false, pr_url: nil)
|
|
351
358
|
# Older API deployments read `surface_filter` for report scoping while `surface`
|
|
352
359
|
# identifies the run's overlay. Send both during the rollout; current servers use
|
|
353
360
|
# `surface` for both meanings.
|
|
354
361
|
body = { surface: surface, surface_filter: surface, junit_xmls: Array(junit_xmls) }
|
|
355
362
|
body[:results] = results if results && !results.empty?
|
|
356
363
|
body[:level] = level if level
|
|
364
|
+
# A pull request's run is judged on what it changes, not on what the default branch
|
|
365
|
+
# already owes. Only sent when asked for, so a default-branch run stays the
|
|
366
|
+
# standing verdict.
|
|
367
|
+
if pull_request
|
|
368
|
+
body[:pull_request] = true
|
|
369
|
+
body[:pr_url] = pr_url if pr_url && !pr_url.to_s.empty?
|
|
370
|
+
end
|
|
357
371
|
post("/api/projects/#{project_id}/parity/preview", body)
|
|
358
372
|
end
|
|
359
373
|
|
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
|
|
|
@@ -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
|
-
|
|
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,
|
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/show.rb
CHANGED
|
@@ -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/commands/showroom.rb
CHANGED
|
@@ -23,6 +23,7 @@ module Fp
|
|
|
23
23
|
help_usage 'fp showroom --project <slug>',
|
|
24
24
|
'fp showroom media --project <slug>',
|
|
25
25
|
'fp showroom media add <file> --project <slug> [--surface <key>] [--caption "..."] [--position N]',
|
|
26
|
+
'fp showroom media add <image> --project <slug> --surface <key> --icon',
|
|
26
27
|
'fp showroom media edit <id> --project <slug> [--caption "..."] [--position N]',
|
|
27
28
|
'fp showroom media remove <id> --project <slug> [--yes]'
|
|
28
29
|
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
@@ -33,6 +34,7 @@ module Fp
|
|
|
33
34
|
help_example 'fp showroom --project stowzilla'
|
|
34
35
|
help_example 'fp showroom media add e2e/checkout.webm --project stowzilla --surface web --caption "Checking out"'
|
|
35
36
|
help_example 'fp showroom media add overview.png --project stowzilla --position 1'
|
|
37
|
+
help_example 'fp showroom media add app-icon.png --project stowzilla --surface ios --icon'
|
|
36
38
|
help_example 'fp showroom media edit <id> --project stowzilla --caption ""'
|
|
37
39
|
help_example 'fp showroom media remove <id> --project stowzilla --yes'
|
|
38
40
|
help_note "Prints the welcome note, the gallery, and each surface's card, then the surfaces that are NOT shown and why.\n" \
|
|
@@ -7,6 +7,7 @@ module Fp
|
|
|
7
7
|
module Commands
|
|
8
8
|
# fp showroom media [--project <slug>] - list the showroom's media
|
|
9
9
|
# fp showroom media add <file> [--surface <key>] [--caption] [--position N]
|
|
10
|
+
# fp showroom media add <image> --surface <key> --icon - set the surface's icon
|
|
10
11
|
# fp showroom media edit <id> [--caption "..."] [--position N]
|
|
11
12
|
# fp showroom media remove <id> [--yes]
|
|
12
13
|
#
|
|
@@ -19,13 +20,19 @@ module Fp
|
|
|
19
20
|
# Uploads reuse attach's flow (presign, PUT the bytes, confirm) and its visual file
|
|
20
21
|
# types — images and videos only. SVG can carry script and PDFs don't render inline,
|
|
21
22
|
# so both are refused before anything is sent.
|
|
23
|
+
#
|
|
24
|
+
# `--icon` (Fizzy #1499) makes the image the surface's own icon in place of the
|
|
25
|
+
# built-in platform logo: one per surface, a new one replaces the old, and removing
|
|
26
|
+
# it brings the built-in logo back.
|
|
22
27
|
class ShowroomMedia < Base
|
|
23
28
|
CONTENT_TYPES = Attach::CONTENT_TYPES.slice(*Attach::VISUAL_EXTENSIONS).freeze
|
|
24
29
|
MAX_FILE_SIZE = Attach::MAX_FILE_SIZE
|
|
25
30
|
MAX_CAPTION_LENGTH = 500
|
|
31
|
+
ICON_EXTENSIONS = %w[png jpg jpeg gif webp].freeze
|
|
32
|
+
MAX_ICON_SIZE = 5 * 1024 * 1024
|
|
26
33
|
|
|
27
34
|
LIST_FLAGS = { project: :string }.freeze
|
|
28
|
-
ADD_FLAGS = { project: :string, surface: :string, caption: :string, position: :string }.freeze
|
|
35
|
+
ADD_FLAGS = { project: :string, surface: :string, caption: :string, position: :string, icon: :boolean }.freeze
|
|
29
36
|
EDIT_FLAGS = { project: :string, caption: :string, position: :string }.freeze
|
|
30
37
|
REMOVE_FLAGS = { project: :string, yes: :boolean }.freeze
|
|
31
38
|
|
|
@@ -83,6 +90,7 @@ module Fp
|
|
|
83
90
|
|
|
84
91
|
# Everything that can be checked locally is checked before a byte is sent.
|
|
85
92
|
file = prepare_file!(path)
|
|
93
|
+
check_icon!(file, opts) if opts[:icon]
|
|
86
94
|
caption = flag_text(opts[:caption]).strip if opts.key?(:caption)
|
|
87
95
|
caption = nil if caption&.empty?
|
|
88
96
|
check_caption!(caption) if caption
|
|
@@ -95,11 +103,12 @@ module Fp
|
|
|
95
103
|
require_project!(opts)
|
|
96
104
|
project_id = resolve_project_id(opts[:project])
|
|
97
105
|
surface = opts[:surface] ? find_surface!(project_id, opts[:surface]) : nil
|
|
106
|
+
purpose = opts[:icon] ? 'icon' : nil
|
|
98
107
|
|
|
99
108
|
presign = fetch!(client.presign_showroom_media(
|
|
100
109
|
project_id,
|
|
101
110
|
filename: file[:filename], content_type: file[:content_type],
|
|
102
|
-
size_bytes: file[:size], surface_id: surface&.dig('id')
|
|
111
|
+
size_bytes: file[:size], surface_id: surface&.dig('id'), purpose: purpose
|
|
103
112
|
), prefix: 'Failed to get upload URL')['presign'] || {}
|
|
104
113
|
|
|
105
114
|
uploaded = client.upload_to_presigned_url(presign['upload_url'], file[:path], file[:content_type])
|
|
@@ -109,13 +118,19 @@ module Fp
|
|
|
109
118
|
project_id,
|
|
110
119
|
media_id: presign['media_id'], s3_key: presign['s3_key'],
|
|
111
120
|
filename: file[:filename], content_type: file[:content_type], size_bytes: file[:size],
|
|
112
|
-
surface_id: surface&.dig('id'), caption: caption, position: position
|
|
121
|
+
surface_id: surface&.dig('id'), caption: caption, position: position, purpose: purpose
|
|
113
122
|
), prefix: 'Failed to confirm upload')['media'] || {}
|
|
114
123
|
|
|
115
124
|
where = surface ? "surface '#{surface['key']}'" : 'the project gallery'
|
|
116
125
|
output.success({ media: item, surface: surface&.dig('key'), project: opts[:project] }) do
|
|
117
|
-
|
|
118
|
-
|
|
126
|
+
if purpose
|
|
127
|
+
puts "Set #{file[:filename]} as the icon for #{where} in project '#{opts[:project]}'."
|
|
128
|
+
puts "Remove it to go back to the built-in logo: fp showroom media remove #{item['id']} " \
|
|
129
|
+
"--project #{opts[:project]}"
|
|
130
|
+
else
|
|
131
|
+
puts "Added #{file[:filename]} to #{where} in project '#{opts[:project]}'."
|
|
132
|
+
print_item_detail(item)
|
|
133
|
+
end
|
|
119
134
|
end
|
|
120
135
|
end
|
|
121
136
|
|
|
@@ -239,6 +254,22 @@ module Fp
|
|
|
239
254
|
{ path: path, filename: filename, content_type: content_type, size: size }
|
|
240
255
|
end
|
|
241
256
|
|
|
257
|
+
# An icon is a still picture on one surface, with nothing to caption or order.
|
|
258
|
+
def check_icon!(file, opts)
|
|
259
|
+
problem =
|
|
260
|
+
if flag_text(opts[:surface]).strip.empty? then '--icon needs --surface <key>: an icon belongs to a surface.'
|
|
261
|
+
elsif opts.key?(:caption) || opts.key?(:position) then '--icon takes no --caption or --position.'
|
|
262
|
+
elsif !ICON_EXTENSIONS.include?(File.extname(file[:filename]).delete('.').downcase)
|
|
263
|
+
'An icon must be an image (png, jpg, jpeg, gif, webp).'
|
|
264
|
+
elsif file[:size] > MAX_ICON_SIZE
|
|
265
|
+
"Icon too large: #{format_size(file[:size])} (max #{format_size(MAX_ICON_SIZE)})"
|
|
266
|
+
end
|
|
267
|
+
return unless problem
|
|
268
|
+
|
|
269
|
+
output.error(problem)
|
|
270
|
+
exit 1
|
|
271
|
+
end
|
|
272
|
+
|
|
242
273
|
def check_caption!(caption)
|
|
243
274
|
return if caption.length <= MAX_CAPTION_LENGTH
|
|
244
275
|
|
|
@@ -305,6 +336,7 @@ module Fp
|
|
|
305
336
|
|
|
306
337
|
def print_item_line(item)
|
|
307
338
|
position = item['position'].to_i.positive? ? "##{item['position'].to_i}" : '-'
|
|
339
|
+
position = 'icon' if item['purpose'] == 'icon'
|
|
308
340
|
caption = item['caption'].to_s.strip
|
|
309
341
|
line = " #{item['id']} #{position.ljust(3)} #{item['media_kind'].to_s.ljust(5)} #{item['filename']}"
|
|
310
342
|
line += " \"#{caption}\"" unless caption.empty?
|
data/lib/fp/version.rb
CHANGED
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.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-
|
|
11
|
+
date: 2026-10-23 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: net-http
|