featureparity 0.0.4 → 0.0.6
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/cli.rb +1 -0
- data/lib/fp/client.rb +29 -0
- data/lib/fp/commands/ci_report.rb +135 -0
- data/lib/fp/commands/help.rb +29 -0
- data/lib/fp/commands/propose.rb +13 -1
- data/lib/fp/commands/show.rb +55 -22
- data/lib/fp/commands/surfaces.rb +81 -2
- data/lib/fp/commands.rb +1 -0
- data/lib/fp/junit.rb +49 -5
- data/lib/fp/version.rb +1 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 59cbbdb1a7c4c7af5ab1d7016d22c7fdf670855c9e75a5c9d7350752c885b625
|
|
4
|
+
data.tar.gz: 25d42454129c6d27c222e5d512578991b13a2abda65af67f8f39139550d24e96
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 639bc26fbac4a013d0cef2051c438f1019ce915b5ce90537fe6cea0339f443f27234ae85062ba22f93cd989e7c6982390fb3be3068cd63bf658e081795243fca
|
|
7
|
+
data.tar.gz: 0db47c4854e39bd6410c44839c26338d79833d129e6f68d3de26e83afff12a45b7199348fee0f1528a35697caace3e8d8084ee31438ec58474d175dcaf9a040c
|
data/lib/fp/cli.rb
CHANGED
data/lib/fp/client.rb
CHANGED
|
@@ -27,6 +27,11 @@ module Fp
|
|
|
27
27
|
get("/api/projects/#{project_id}/surfaces")
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
+
# POST /projects/:id/surfaces
|
|
31
|
+
def create_surface(project_id, params)
|
|
32
|
+
post("/api/projects/#{project_id}/surfaces", params)
|
|
33
|
+
end
|
|
34
|
+
|
|
30
35
|
# GET /requirements
|
|
31
36
|
def list_requirements(project_id: nil, status: nil, category: nil)
|
|
32
37
|
params = {}
|
|
@@ -67,12 +72,36 @@ module Fp
|
|
|
67
72
|
put("/api/requirements/#{id}", params)
|
|
68
73
|
end
|
|
69
74
|
|
|
75
|
+
# GET /projects/:project_id/evidence — the receipts already filed for a project,
|
|
76
|
+
# optionally narrowed to one requirement slug and/or surface.
|
|
77
|
+
def list_evidence(project_id, slug: nil, surface: nil)
|
|
78
|
+
params = {}
|
|
79
|
+
params[:slug] = slug if slug
|
|
80
|
+
params[:surface] = surface if surface
|
|
81
|
+
get("/api/projects/#{project_id}/evidence", params)
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
# POST /projects/:project_id/evidence (direct reporting)
|
|
71
85
|
def report_evidence(params)
|
|
72
86
|
project_id = params.delete(:project_id)
|
|
73
87
|
post("/api/projects/#{project_id}/evidence", params)
|
|
74
88
|
end
|
|
75
89
|
|
|
90
|
+
# POST /projects/:project_id/ci_results — the CI-only path.
|
|
91
|
+
#
|
|
92
|
+
# Unlike report_evidence (agents: present/stub), this hands the raw JUnit XML
|
|
93
|
+
# to the backend, which parses it, maps testcases to fp:<slug> markers, and
|
|
94
|
+
# writes passing/failing evidence per surface. It also marks previously
|
|
95
|
+
# passing slugs that have vanished from the results as failing. This is the
|
|
96
|
+
# half of the loop only CI is allowed to close (#1222).
|
|
97
|
+
def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:)
|
|
98
|
+
post("/api/projects/#{project_id}/ci_results", {
|
|
99
|
+
ci_url: ci_url,
|
|
100
|
+
surface: surface,
|
|
101
|
+
junit_xml: junit_xml
|
|
102
|
+
})
|
|
103
|
+
end
|
|
104
|
+
|
|
76
105
|
# GET /matrix (when API is ready)
|
|
77
106
|
def get_matrix(project_id, format: :json)
|
|
78
107
|
path = format == :csv ? '/api/matrix.csv' : '/api/matrix'
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# CI-only evidence reporting from a JUnit XML report.
|
|
6
|
+
#
|
|
7
|
+
# fp ci-report --junit <path> --project <slug> --surface X --ci-url URL
|
|
8
|
+
#
|
|
9
|
+
# This is the CI counterpart to `fp report --junit`. Where `fp report`
|
|
10
|
+
# writes agent evidence (present/stub) and never claims a test passed, this
|
|
11
|
+
# command hands the raw JUnit XML to the backend's CI ingest endpoint, which:
|
|
12
|
+
#
|
|
13
|
+
# - maps each testcase to its fp:<slug> marker (server-side, same
|
|
14
|
+
# file-scanning convention as the CLI),
|
|
15
|
+
# - writes passing / failing evidence per requirement for the surface,
|
|
16
|
+
# - and marks any slug that was passing but has now vanished from the
|
|
17
|
+
# report as failing.
|
|
18
|
+
#
|
|
19
|
+
# That last part is why the whole report goes up as one document rather than
|
|
20
|
+
# per-slug: the server can only detect a disappeared test by diffing the full
|
|
21
|
+
# result set against what it had. Reporting passing/failing is a privilege of
|
|
22
|
+
# CI (#1222) — agents can't reach this path, they only ever report evidence
|
|
23
|
+
# via `fp report`.
|
|
24
|
+
#
|
|
25
|
+
# Multiple --junit flags may be passed to ingest several reports for the same
|
|
26
|
+
# surface in one invocation (e.g. a suite split across shards). Each is
|
|
27
|
+
# uploaded in turn.
|
|
28
|
+
class CiReport < Base
|
|
29
|
+
KNOWN_FLAGS = {
|
|
30
|
+
project: :string,
|
|
31
|
+
surface: :string,
|
|
32
|
+
ci_url: :string,
|
|
33
|
+
junit: :string
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
def run(args)
|
|
37
|
+
# Collect every --junit occurrence (parse_flags keeps only the last of a
|
|
38
|
+
# repeated flag, so pull them out first).
|
|
39
|
+
junit_paths, rest = extract_repeated(args, '--junit')
|
|
40
|
+
opts, _positional = parse_flags(rest, KNOWN_FLAGS)
|
|
41
|
+
|
|
42
|
+
require_flag(opts, :project)
|
|
43
|
+
require_flag(opts, :surface)
|
|
44
|
+
require_flag(opts, :ci_url)
|
|
45
|
+
|
|
46
|
+
if junit_paths.empty?
|
|
47
|
+
output.error('--junit <path> is required (may be given more than once).')
|
|
48
|
+
exit 1
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
missing = junit_paths.reject { |p| File.file?(p) }
|
|
52
|
+
unless missing.empty?
|
|
53
|
+
output.error("JUnit file(s) not found: #{missing.join(', ')}")
|
|
54
|
+
exit 1
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
project_id = resolve_project_id(opts[:project])
|
|
58
|
+
|
|
59
|
+
totals = { created: 0, updated: 0, missing_failing: 0, reports: [] }
|
|
60
|
+
|
|
61
|
+
junit_paths.each do |path|
|
|
62
|
+
xml = File.read(path)
|
|
63
|
+
result = client.ingest_ci_results(
|
|
64
|
+
project_id,
|
|
65
|
+
ci_url: opts[:ci_url],
|
|
66
|
+
surface: opts[:surface],
|
|
67
|
+
junit_xml: xml
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
unless result[:ok]
|
|
71
|
+
output.error("Failed to ingest #{path}: #{result[:error]}", status: result[:status])
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
data = result[:data] || {}
|
|
76
|
+
totals[:created] += data['evidence_created'].to_i
|
|
77
|
+
totals[:updated] += data['evidence_updated'].to_i
|
|
78
|
+
totals[:missing_failing] += data['missing_marked_failing'].to_i
|
|
79
|
+
totals[:reports] << { path: path, results: data['results'] || [] }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
emit_summary(opts, totals)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Pull every occurrence of a repeatable flag (and its value) out of args,
|
|
88
|
+
# returning [collected_values, remaining_args]. Leaves all other flags for
|
|
89
|
+
# the normal parser.
|
|
90
|
+
def extract_repeated(args, flag)
|
|
91
|
+
values = []
|
|
92
|
+
rest = []
|
|
93
|
+
i = 0
|
|
94
|
+
while i < args.length
|
|
95
|
+
arg = args[i]
|
|
96
|
+
if arg == flag && i + 1 < args.length
|
|
97
|
+
values << args[i + 1]
|
|
98
|
+
i += 2
|
|
99
|
+
elsif arg.start_with?("#{flag}=")
|
|
100
|
+
values << arg.split('=', 2)[1]
|
|
101
|
+
i += 1
|
|
102
|
+
else
|
|
103
|
+
rest << arg
|
|
104
|
+
i += 1
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
[values, rest]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def emit_summary(opts, totals)
|
|
111
|
+
data = {
|
|
112
|
+
project: opts[:project],
|
|
113
|
+
surface: opts[:surface],
|
|
114
|
+
ci_url: opts[:ci_url],
|
|
115
|
+
evidence_created: totals[:created],
|
|
116
|
+
evidence_updated: totals[:updated],
|
|
117
|
+
missing_marked_failing: totals[:missing_failing],
|
|
118
|
+
reports: totals[:reports]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
output.success(data) do
|
|
122
|
+
puts "Ingested CI results for surface '#{opts[:surface]}':"
|
|
123
|
+
totals[:reports].each do |report|
|
|
124
|
+
report[:results].each do |r|
|
|
125
|
+
glyph = r['status'] == 'passing' ? '✓' : (r['status'] == 'failing' ? '✗' : '•')
|
|
126
|
+
puts " #{glyph} #{r['slug']} (#{r['status']}) #{r['source_file']}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
puts
|
|
130
|
+
puts " created: #{totals[:created]} updated: #{totals[:updated]} marked failing (missing): #{totals[:missing_failing]}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -14,11 +14,13 @@ module Fp
|
|
|
14
14
|
COMMANDS
|
|
15
15
|
projects List projects you have access to
|
|
16
16
|
surfaces --project <slug> List surfaces for a project
|
|
17
|
+
surfaces add <key> --project ... Create a surface
|
|
17
18
|
list --project <slug> List active requirements
|
|
18
19
|
show <slug> --project <slug> Show requirement details
|
|
19
20
|
propose --project <slug> ... Propose a new requirement (as draft)
|
|
20
21
|
report <slug> --project <slug>... Report evidence for a requirement
|
|
21
22
|
report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
|
|
23
|
+
ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
|
|
22
24
|
matrix --project <slug> Show the parity matrix
|
|
23
25
|
profile add|list Manage named profiles
|
|
24
26
|
config set|get|unset|list Manage global settings
|
|
@@ -68,6 +70,15 @@ module Fp
|
|
|
68
70
|
# List projects
|
|
69
71
|
fp projects
|
|
70
72
|
|
|
73
|
+
# List the surfaces a project tracks (requirements may only name these)
|
|
74
|
+
fp surfaces --project stowzilla
|
|
75
|
+
|
|
76
|
+
# Create a surface (agents can do this — no need to wait on the web app)
|
|
77
|
+
fp surfaces add customer_android --project stowzilla \\
|
|
78
|
+
--name "Customer Android App" \\
|
|
79
|
+
--kind android \\
|
|
80
|
+
--repo stowzilla/customer-android
|
|
81
|
+
|
|
71
82
|
# List requirements for a project
|
|
72
83
|
fp list --project stowzilla
|
|
73
84
|
|
|
@@ -89,6 +100,14 @@ module Fp
|
|
|
89
100
|
--acceptance "QR code is printed and scannable" \\
|
|
90
101
|
--category functional
|
|
91
102
|
|
|
103
|
+
# Nest a requirement under a parent (--parent takes the parent's SLUG).
|
|
104
|
+
# A child's --required surfaces must be a subset of its parent's.
|
|
105
|
+
fp propose --project stowzilla \\
|
|
106
|
+
--slug print_container_qr_ios \\
|
|
107
|
+
--name "Print QR on iOS" \\
|
|
108
|
+
--parent print_container_qr \\
|
|
109
|
+
--required customer_ios
|
|
110
|
+
|
|
92
111
|
# Report evidence for a requirement
|
|
93
112
|
fp report print_container_qr --project stowzilla \\
|
|
94
113
|
--surface customer_android \\
|
|
@@ -109,6 +128,16 @@ module Fp
|
|
|
109
128
|
# --base-dir sets where relative test file paths in the report resolve from
|
|
110
129
|
# Skipped tests are reported as 'stub'; everything else as 'present'.
|
|
111
130
|
|
|
131
|
+
# CI ONLY: ingest real pass/fail results. Run in your CI pipeline after
|
|
132
|
+
# the suite (on green AND red builds). Unlike `fp report`, this writes
|
|
133
|
+
# passing/failing evidence and marks vanished-but-previously-passing
|
|
134
|
+
# slugs as failing. This is the half of the loop that turns the matrix
|
|
135
|
+
# circles into ✓ / ✗.
|
|
136
|
+
fp ci-report --junit junit.xml --project stowzilla \\
|
|
137
|
+
--surface api \\
|
|
138
|
+
--ci-url "$CI_RUN_URL"
|
|
139
|
+
# Pass --junit more than once to ingest several reports for one surface.
|
|
140
|
+
|
|
112
141
|
# Show the parity matrix
|
|
113
142
|
fp matrix --project stowzilla
|
|
114
143
|
|
data/lib/fp/commands/propose.rb
CHANGED
|
@@ -13,10 +13,14 @@ module Fp
|
|
|
13
13
|
required: :string,
|
|
14
14
|
acceptance: :string,
|
|
15
15
|
parent: :string,
|
|
16
|
-
category: :string
|
|
16
|
+
category: :string,
|
|
17
|
+
source_type: :string,
|
|
18
|
+
source_url: :string,
|
|
19
|
+
source_context: :string
|
|
17
20
|
}.freeze
|
|
18
21
|
|
|
19
22
|
VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
|
|
23
|
+
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
|
|
20
24
|
|
|
21
25
|
def run(args)
|
|
22
26
|
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
@@ -31,6 +35,11 @@ module Fp
|
|
|
31
35
|
exit 1
|
|
32
36
|
end
|
|
33
37
|
|
|
38
|
+
if opts[:source_type] && !VALID_SOURCE_TYPES.include?(opts[:source_type])
|
|
39
|
+
output.error("Invalid source type '#{opts[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}")
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
|
|
34
43
|
project_id = resolve_project_id(opts[:project])
|
|
35
44
|
|
|
36
45
|
# Parse required surfaces (comma-separated)
|
|
@@ -49,6 +58,9 @@ module Fp
|
|
|
49
58
|
params[:why] = opts[:why] if opts[:why]
|
|
50
59
|
params[:acceptance] = opts[:acceptance] if opts[:acceptance]
|
|
51
60
|
params[:category] = opts[:category] if opts[:category]
|
|
61
|
+
params[:source_type] = opts[:source_type] if opts[:source_type]
|
|
62
|
+
params[:source_url] = opts[:source_url] if opts[:source_url]
|
|
63
|
+
params[:source_context] = opts[:source_context] if opts[:source_context]
|
|
52
64
|
|
|
53
65
|
# Resolve parent slug to parent_id if provided
|
|
54
66
|
if opts[:parent]
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -25,21 +25,49 @@ module Fp
|
|
|
25
25
|
|
|
26
26
|
requirement = result[:data]['requirement']
|
|
27
27
|
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
# Evidence is not embedded in the requirement payload — it lives on the project's
|
|
29
|
+
# evidence collection, keyed by slug. Fetch it, or `fp show` reports "(none)" for
|
|
30
|
+
# every requirement no matter how many receipts have been filed.
|
|
31
|
+
evidence = fetch_evidence(project_id, slug)
|
|
32
|
+
|
|
33
|
+
# Agents only ever speak slugs, so translate the parent's ID back into one.
|
|
34
|
+
parent_slug = resolve_parent_slug(project_id, requirement['parent_id'])
|
|
35
|
+
|
|
36
|
+
payload = result[:data].merge('evidence' => evidence)
|
|
37
|
+
payload['parent_slug'] = parent_slug if parent_slug
|
|
38
|
+
|
|
39
|
+
output.success(payload) do
|
|
40
|
+
print_requirement(requirement, evidence: evidence, parent_slug: parent_slug)
|
|
30
41
|
end
|
|
31
42
|
end
|
|
32
43
|
|
|
33
44
|
private
|
|
34
45
|
|
|
35
|
-
def
|
|
46
|
+
def fetch_evidence(project_id, slug)
|
|
47
|
+
result = client.list_evidence(project_id, slug: slug)
|
|
48
|
+
return [] unless result[:ok]
|
|
49
|
+
|
|
50
|
+
result[:data]['evidence'] || []
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolve_parent_slug(project_id, parent_id)
|
|
54
|
+
return nil if parent_id.nil? || parent_id.to_s.empty?
|
|
55
|
+
|
|
56
|
+
result = client.list_requirements(project_id: project_id)
|
|
57
|
+
return nil unless result[:ok]
|
|
58
|
+
|
|
59
|
+
parent = (result[:data]['requirements'] || []).find { |r| r['id'] == parent_id }
|
|
60
|
+
parent && parent['slug']
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def print_requirement(req, evidence: [], parent_slug: nil)
|
|
36
64
|
puts "#{req['title']}"
|
|
37
65
|
puts "=" * req['title'].length
|
|
38
66
|
puts
|
|
39
67
|
puts "Slug: #{req['slug']}"
|
|
40
68
|
puts "Status: #{req['status']}"
|
|
41
69
|
puts "Category: #{req['category'] || '(none)'}"
|
|
42
|
-
puts "Parent: #{req['parent_id'] || '(none)'}"
|
|
70
|
+
puts "Parent: #{parent_slug || req['parent_id'] || '(none)'}"
|
|
43
71
|
puts
|
|
44
72
|
|
|
45
73
|
surfaces = req['required_surfaces'] || []
|
|
@@ -63,26 +91,31 @@ module Fp
|
|
|
63
91
|
puts
|
|
64
92
|
end
|
|
65
93
|
|
|
66
|
-
# Evidence receipts
|
|
67
|
-
|
|
68
|
-
if evidence.
|
|
69
|
-
puts 'Evidence:'
|
|
70
|
-
evidence.each do |e|
|
|
71
|
-
puts " #{e['surface']}:"
|
|
72
|
-
puts " Status: #{e['status']}"
|
|
73
|
-
puts " File: #{e['file']}" if e['file']
|
|
74
|
-
puts " Repo: #{e['repo']}" if e['repo']
|
|
75
|
-
puts " PR: #{e['pr_url']}" if e['pr_url']
|
|
76
|
-
puts " Work: #{e['work_item_url']}" if e['work_item_url']
|
|
77
|
-
puts " CI: #{e['ci_url']}" if e['ci_url']
|
|
78
|
-
puts " Title: #{e['example_title']}" if e['example_title']
|
|
79
|
-
puts " By: #{e['reported_by']}" if e['reported_by']
|
|
80
|
-
puts
|
|
81
|
-
end
|
|
82
|
-
else
|
|
83
|
-
puts 'Evidence:'
|
|
94
|
+
# Evidence receipts, newest first, grouped per surface.
|
|
95
|
+
puts 'Evidence:'
|
|
96
|
+
if evidence.empty?
|
|
84
97
|
puts ' (none reported)'
|
|
85
98
|
puts
|
|
99
|
+
else
|
|
100
|
+
evidence
|
|
101
|
+
.sort_by { |e| e['reported_at'].to_s }
|
|
102
|
+
.reverse
|
|
103
|
+
.group_by { |e| e['surface'] }
|
|
104
|
+
.each do |surface, records|
|
|
105
|
+
puts " #{surface}:"
|
|
106
|
+
records.each do |e|
|
|
107
|
+
puts " Status: #{e['status']}"
|
|
108
|
+
puts " File: #{e['source_file']}" if e['source_file']
|
|
109
|
+
puts " Repo: #{e['repo']}" if e['repo']
|
|
110
|
+
puts " PR: #{e['pr_url']}" if e['pr_url']
|
|
111
|
+
puts " Work: #{e['work_item_url']}" if e['work_item_url']
|
|
112
|
+
puts " CI: #{e['ci_url']}" if e['ci_url']
|
|
113
|
+
puts " Title: #{e['example_title']}" if e['example_title']
|
|
114
|
+
puts " By: #{e['source']}" if e['source']
|
|
115
|
+
puts " At: #{e['reported_at']}" if e['reported_at']
|
|
116
|
+
puts
|
|
117
|
+
end
|
|
118
|
+
end
|
|
86
119
|
end
|
|
87
120
|
|
|
88
121
|
puts "Created: #{req['created_at']}"
|
data/lib/fp/commands/surfaces.rb
CHANGED
|
@@ -2,9 +2,38 @@
|
|
|
2
2
|
|
|
3
3
|
module Fp
|
|
4
4
|
module Commands
|
|
5
|
-
# fp surfaces --project <slug>
|
|
5
|
+
# fp surfaces --project <slug> - list available surfaces for a project
|
|
6
|
+
# fp surfaces add <key> --project <slug> - create a surface
|
|
7
|
+
#
|
|
8
|
+
# `add` exists because a requirement can only name surfaces that already exist, so an
|
|
9
|
+
# agent onboarding a project it hasn't seen before would otherwise be blocked waiting
|
|
10
|
+
# on a human to click through the web app.
|
|
6
11
|
class Surfaces < Base
|
|
12
|
+
ADD_FLAGS = {
|
|
13
|
+
project: :string,
|
|
14
|
+
name: :string,
|
|
15
|
+
kind: :string,
|
|
16
|
+
audience: :string,
|
|
17
|
+
repo: :string,
|
|
18
|
+
default_branch: :string
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
VALID_KINDS = %w[api web android ios other].freeze
|
|
22
|
+
VALID_AUDIENCES = %w[customer ops both internal].freeze
|
|
23
|
+
|
|
7
24
|
def run(args)
|
|
25
|
+
# Subcommand dispatch. Bare `fp surfaces --project X` stays a list, so existing
|
|
26
|
+
# callers (and the docs) keep working.
|
|
27
|
+
if args.first == 'add'
|
|
28
|
+
add(args[1..])
|
|
29
|
+
else
|
|
30
|
+
list(args)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def list(args)
|
|
8
37
|
opts, = parse_flags(args)
|
|
9
38
|
require_flag(opts, :project)
|
|
10
39
|
|
|
@@ -22,13 +51,63 @@ module Fp
|
|
|
22
51
|
output.success({ surfaces: surfaces, project: opts[:project] }) do
|
|
23
52
|
if surfaces.empty?
|
|
24
53
|
puts "No surfaces configured for project '#{opts[:project]}'."
|
|
25
|
-
puts
|
|
54
|
+
puts "Add one with: fp surfaces add <key> --project #{opts[:project]} --name \"<Name>\""
|
|
26
55
|
else
|
|
27
56
|
puts "Surfaces for '#{opts[:project]}':"
|
|
28
57
|
surfaces.each { |s| puts " - #{s}" }
|
|
29
58
|
end
|
|
30
59
|
end
|
|
31
60
|
end
|
|
61
|
+
|
|
62
|
+
def add(args)
|
|
63
|
+
opts, positional = parse_flags(args, ADD_FLAGS)
|
|
64
|
+
require_flag(opts, :project)
|
|
65
|
+
|
|
66
|
+
key = positional.first
|
|
67
|
+
if key.nil? || key.strip.empty?
|
|
68
|
+
output.error('Usage: fp surfaces add <key> --project <slug> [--name "Name"] [--kind api|web|android|ios|other]')
|
|
69
|
+
exit 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
validate_choice!(opts[:kind], VALID_KINDS, '--kind')
|
|
73
|
+
validate_choice!(opts[:audience], VALID_AUDIENCES, '--audience')
|
|
74
|
+
|
|
75
|
+
project_id = resolve_project_id(opts[:project])
|
|
76
|
+
|
|
77
|
+
# The API derives key from name and defaults kind/audience/default_branch, but we
|
|
78
|
+
# send the key explicitly because here the key is what the caller actually typed —
|
|
79
|
+
# `add api --name "Backend API"` must produce `api`, not `backend_api`.
|
|
80
|
+
params = { key: key, name: opts[:name] || key }
|
|
81
|
+
params[:kind] = opts[:kind] if opts[:kind]
|
|
82
|
+
params[:audience] = opts[:audience] if opts[:audience]
|
|
83
|
+
params[:repo] = opts[:repo] if opts[:repo]
|
|
84
|
+
params[:default_branch] = opts[:default_branch] if opts[:default_branch]
|
|
85
|
+
|
|
86
|
+
result = client.create_surface(project_id, params)
|
|
87
|
+
|
|
88
|
+
unless result[:ok]
|
|
89
|
+
output.error(result[:error], status: result[:status])
|
|
90
|
+
exit 1
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
surface = result[:data]['surface'] || {}
|
|
94
|
+
|
|
95
|
+
output.success(result[:data]) do
|
|
96
|
+
puts "Created surface '#{surface['key'] || key}' in project '#{opts[:project]}'."
|
|
97
|
+
puts
|
|
98
|
+
puts " Name: #{surface['name']}"
|
|
99
|
+
puts " Kind: #{surface['kind']}"
|
|
100
|
+
puts " Audience: #{surface['audience']}"
|
|
101
|
+
puts " Repo: #{surface['repo'] || '(none)'}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_choice!(value, allowed, flag)
|
|
106
|
+
return if value.nil? || allowed.include?(value)
|
|
107
|
+
|
|
108
|
+
output.error("Invalid #{flag} '#{value}'. Must be one of: #{allowed.join(', ')}")
|
|
109
|
+
exit 1
|
|
110
|
+
end
|
|
32
111
|
end
|
|
33
112
|
end
|
|
34
113
|
end
|
data/lib/fp/commands.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative 'commands/list'
|
|
|
7
7
|
require_relative 'commands/show'
|
|
8
8
|
require_relative 'commands/propose'
|
|
9
9
|
require_relative 'commands/report'
|
|
10
|
+
require_relative 'commands/ci_report'
|
|
10
11
|
require_relative 'commands/matrix'
|
|
11
12
|
require_relative 'commands/profile'
|
|
12
13
|
require_relative 'commands/config_cmd'
|
data/lib/fp/junit.rb
CHANGED
|
@@ -34,6 +34,16 @@ module Fp
|
|
|
34
34
|
# (matching the propose/report convention).
|
|
35
35
|
MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?/.freeze
|
|
36
36
|
|
|
37
|
+
# A marker only counts when it is a comment on its own line — the documented
|
|
38
|
+
# convention ("the fp:<slug> comment sits immediately above its test"). Requiring a
|
|
39
|
+
# line-leading comment token is what keeps marker-shaped text inside string literals,
|
|
40
|
+
# fixtures and docs from being mistaken for a real annotation. Without it, any test
|
|
41
|
+
# suite that has tests *about* fp markers reports evidence for its own fixtures.
|
|
42
|
+
#
|
|
43
|
+
# Covers #, //, --, and both the opening and continuation lines of /* ... */ blocks,
|
|
44
|
+
# which spans every language the marker convention documents.
|
|
45
|
+
COMMENT_LINE_RE = %r{\A\s*(?:\#|//|--|/\*|\*)}.freeze
|
|
46
|
+
|
|
37
47
|
module_function
|
|
38
48
|
|
|
39
49
|
# Parse a JUnit XML file into an array of TestCase structs.
|
|
@@ -52,10 +62,14 @@ module Fp
|
|
|
52
62
|
cases = []
|
|
53
63
|
|
|
54
64
|
doc.each_element('//testcase') do |el|
|
|
65
|
+
parent = el.parent
|
|
66
|
+
suite_name = parent && parent.expanded_name == 'testsuite' ? parent.attributes['name'] : nil
|
|
67
|
+
classname = el.attributes['classname']
|
|
68
|
+
|
|
55
69
|
cases << TestCase.new(
|
|
56
70
|
name: el.attributes['name'],
|
|
57
|
-
classname:
|
|
58
|
-
file: el.attributes['file'],
|
|
71
|
+
classname: classname,
|
|
72
|
+
file: resolve_file(el.attributes['file'], classname, suite_name),
|
|
59
73
|
line: (el.attributes['line'] && el.attributes['line'].to_i),
|
|
60
74
|
status: testcase_status(el)
|
|
61
75
|
)
|
|
@@ -64,6 +78,26 @@ module Fp
|
|
|
64
78
|
cases
|
|
65
79
|
end
|
|
66
80
|
|
|
81
|
+
# Runners disagree about where the source file goes. Minitest- and RSpec-style reports
|
|
82
|
+
# put it on the testcase's `file` attribute; vitest and jest omit `file` entirely and
|
|
83
|
+
# carry the path in `classname` and the enclosing `<testsuite name>`. Markers live in
|
|
84
|
+
# the file, so fall back through the alternatives instead of silently finding nothing.
|
|
85
|
+
def resolve_file(file, classname, suite_name)
|
|
86
|
+
return file if file && !file.empty?
|
|
87
|
+
|
|
88
|
+
[classname, suite_name].find { |candidate| path_like?(candidate) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Distinguish a source path from a bare test-class name. Deliberately conservative:
|
|
92
|
+
# a Java-style `com.example.FooTest` classname must not be mistaken for a file.
|
|
93
|
+
TEST_FILE_EXT_RE = /\.(rb|js|jsx|mjs|cjs|ts|tsx|kt|kts|swift|java|py|go|cs|php|rs|scala)\z/.freeze
|
|
94
|
+
|
|
95
|
+
def path_like?(value)
|
|
96
|
+
return false if value.nil? || value.empty?
|
|
97
|
+
|
|
98
|
+
value.include?('/') || value.match?(TEST_FILE_EXT_RE)
|
|
99
|
+
end
|
|
100
|
+
|
|
67
101
|
# Determine present/skipped for a testcase element.
|
|
68
102
|
# Failures/errors still count as "present" for agent evidence — the test
|
|
69
103
|
# exists and was executed. Only skipped/pending tests become stubs.
|
|
@@ -113,6 +147,12 @@ module Fp
|
|
|
113
147
|
markers = markers_for(cases.first, base_dir, marker_cache)
|
|
114
148
|
next if markers.empty?
|
|
115
149
|
|
|
150
|
+
# A title is only trustworthy when we know which test the marker annotates: either
|
|
151
|
+
# the report carried line numbers, or the file holds a single test. Otherwise the
|
|
152
|
+
# marker was matched file-wide and naming any one test would be a guess — vitest
|
|
153
|
+
# and jest emit no line numbers, so this is the common case, not an edge one.
|
|
154
|
+
titles_reliable = cases.any?(&:line) || cases.length == 1
|
|
155
|
+
|
|
116
156
|
cases.each do |tc|
|
|
117
157
|
marker = marker_for(tc, cases, markers)
|
|
118
158
|
next unless marker
|
|
@@ -126,7 +166,7 @@ module Fp
|
|
|
126
166
|
if surface.nil? || surface.empty?
|
|
127
167
|
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
128
168
|
else
|
|
129
|
-
record_binding(bindings, marker[:slug], surface, tc)
|
|
169
|
+
record_binding(bindings, marker[:slug], surface, tc, titles_reliable: titles_reliable)
|
|
130
170
|
end
|
|
131
171
|
end
|
|
132
172
|
end
|
|
@@ -163,7 +203,7 @@ module Fp
|
|
|
163
203
|
candidate
|
|
164
204
|
end
|
|
165
205
|
|
|
166
|
-
def record_binding(bindings, slug, surface, testcase)
|
|
206
|
+
def record_binding(bindings, slug, surface, testcase, titles_reliable: true)
|
|
167
207
|
rel_file = testcase.file
|
|
168
208
|
key = [slug, surface, rel_file]
|
|
169
209
|
state = testcase.stub? ? 'stub' : 'present'
|
|
@@ -173,7 +213,7 @@ module Fp
|
|
|
173
213
|
return if existing && !(existing.state == 'stub' && state == 'present')
|
|
174
214
|
|
|
175
215
|
bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file,
|
|
176
|
-
title: testcase.name, state: state)
|
|
216
|
+
title: (testcase.name if titles_reliable), state: state)
|
|
177
217
|
end
|
|
178
218
|
|
|
179
219
|
# Load and cache the fp:<slug> markers (with line numbers) for a testcase's
|
|
@@ -198,9 +238,13 @@ module Fp
|
|
|
198
238
|
# Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
|
|
199
239
|
# [{ slug:, surfaces: [..], line: }, ...] (1-based line numbers).
|
|
200
240
|
# `surfaces` is [] when the marker pins none (use the default surface).
|
|
241
|
+
#
|
|
242
|
+
# Only comment lines are considered — see COMMENT_LINE_RE.
|
|
201
243
|
def scan_markers(contents)
|
|
202
244
|
markers = []
|
|
203
245
|
contents.each_line.with_index(1) do |line, num|
|
|
246
|
+
next unless line.match?(COMMENT_LINE_RE)
|
|
247
|
+
|
|
204
248
|
line.scan(MARKER_RE) do |(slug, surface_list)|
|
|
205
249
|
surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
|
|
206
250
|
markers << { slug: slug, surfaces: surfaces, line: num }
|
data/lib/fp/version.rb
CHANGED
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.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Stowzilla
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-09-
|
|
11
|
+
date: 2026-09-14 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: net-http
|
|
@@ -95,6 +95,7 @@ files:
|
|
|
95
95
|
- lib/fp/client.rb
|
|
96
96
|
- lib/fp/commands.rb
|
|
97
97
|
- lib/fp/commands/base.rb
|
|
98
|
+
- lib/fp/commands/ci_report.rb
|
|
98
99
|
- lib/fp/commands/config_cmd.rb
|
|
99
100
|
- lib/fp/commands/help.rb
|
|
100
101
|
- lib/fp/commands/list.rb
|