featureparity 0.0.1
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 +7 -0
- data/bin/fp +6 -0
- data/lib/fp/cli.rb +96 -0
- data/lib/fp/client.rb +151 -0
- data/lib/fp/commands/base.rb +90 -0
- data/lib/fp/commands/help.rb +122 -0
- data/lib/fp/commands/list.rb +104 -0
- data/lib/fp/commands/matrix.rb +79 -0
- data/lib/fp/commands/profile.rb +87 -0
- data/lib/fp/commands/projects.rb +32 -0
- data/lib/fp/commands/propose.rb +66 -0
- data/lib/fp/commands/report.rb +117 -0
- data/lib/fp/commands/setup.rb +148 -0
- data/lib/fp/commands/show.rb +92 -0
- data/lib/fp/commands/surfaces.rb +34 -0
- data/lib/fp/commands/version.rb +14 -0
- data/lib/fp/commands.rb +19 -0
- data/lib/fp/config.rb +111 -0
- data/lib/fp/output.rb +71 -0
- data/lib/fp/version.rb +5 -0
- data/lib/fp.rb +14 -0
- metadata +139 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 695b5f522e34a1e886e07177cbbe5ca87b515caf6845e15b50ca9e1c4fb3735e
|
|
4
|
+
data.tar.gz: 18ff57dfb177341dd4a6f2d8dc05e862a53d8e24eb0c2da4172e022986533cc1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9851ef3ea41669276c356fbe2e5a200ec06d59cea82a9d852eaca71a20297319b4eeb32e5a684921cfba3b298bf1e8931f45bc89ad0c59d802128a5f5940b157
|
|
7
|
+
data.tar.gz: 1e075a99c1b02368908639649ff45ba78be33f8e0a03756b0f7782275758f326e01992488508c6930d1f5ac65e47eb6ef7b5e24438482e6eac2b855fe06b2557
|
data/bin/fp
ADDED
data/lib/fp/cli.rb
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
# Main CLI entry point - parses global flags and dispatches to commands
|
|
5
|
+
class CLI
|
|
6
|
+
COMMANDS = {
|
|
7
|
+
'projects' => Commands::Projects,
|
|
8
|
+
'surfaces' => Commands::Surfaces,
|
|
9
|
+
'list' => Commands::List,
|
|
10
|
+
'show' => Commands::Show,
|
|
11
|
+
'propose' => Commands::Propose,
|
|
12
|
+
'report' => Commands::Report,
|
|
13
|
+
'matrix' => Commands::Matrix,
|
|
14
|
+
'profile' => Commands::Profile,
|
|
15
|
+
'setup' => Commands::Setup,
|
|
16
|
+
'version' => Commands::Version,
|
|
17
|
+
'help' => Commands::Help
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def run(argv)
|
|
22
|
+
args = argv.dup
|
|
23
|
+
global_opts = extract_global_options(args)
|
|
24
|
+
|
|
25
|
+
command_name = args.shift || 'help'
|
|
26
|
+
command_class = COMMANDS[command_name]
|
|
27
|
+
|
|
28
|
+
unless command_class
|
|
29
|
+
output = Output.new(json: global_opts[:json])
|
|
30
|
+
output.error("Unknown command: #{command_name}")
|
|
31
|
+
puts "\nRun 'fp help' for usage."
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Profile commands don't need auth
|
|
36
|
+
needs_auth = !%w[profile setup version help].include?(command_name)
|
|
37
|
+
|
|
38
|
+
if needs_auth
|
|
39
|
+
config = Config.new(profile: global_opts[:profile])
|
|
40
|
+
unless config.valid?
|
|
41
|
+
output = Output.new(json: global_opts[:json])
|
|
42
|
+
output.error(config.validation_error.strip)
|
|
43
|
+
exit 1
|
|
44
|
+
end
|
|
45
|
+
client = Client.new(config)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
output = Output.new(json: global_opts[:json])
|
|
49
|
+
command = command_class.new(client: client, output: output, config: config)
|
|
50
|
+
command.run(args)
|
|
51
|
+
rescue Interrupt
|
|
52
|
+
warn "\nAborted."
|
|
53
|
+
exit 130
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
output = Output.new(json: global_opts&.dig(:json))
|
|
56
|
+
output.error(e.message)
|
|
57
|
+
exit 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def extract_global_options(args)
|
|
63
|
+
opts = { json: false, profile: nil }
|
|
64
|
+
|
|
65
|
+
# Extract --json flag
|
|
66
|
+
if args.delete('--json')
|
|
67
|
+
opts[:json] = true
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Extract --profile flag
|
|
71
|
+
if (idx = args.index('--profile'))
|
|
72
|
+
args.delete_at(idx)
|
|
73
|
+
value = args[idx]
|
|
74
|
+
if value.nil? || value.start_with?('-')
|
|
75
|
+
$stderr.puts "Error: --profile requires a value"
|
|
76
|
+
exit 1
|
|
77
|
+
end
|
|
78
|
+
opts[:profile] = args.delete_at(idx)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Also check short form -p
|
|
82
|
+
if (idx = args.index('-p'))
|
|
83
|
+
args.delete_at(idx)
|
|
84
|
+
value = args[idx]
|
|
85
|
+
if value.nil? || value.start_with?('-')
|
|
86
|
+
$stderr.puts "Error: -p requires a profile name"
|
|
87
|
+
exit 1
|
|
88
|
+
end
|
|
89
|
+
opts[:profile] = args.delete_at(idx)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
opts
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
data/lib/fp/client.rb
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
require 'json'
|
|
6
|
+
|
|
7
|
+
module Fp
|
|
8
|
+
# HTTP client for the FeatureParity API
|
|
9
|
+
class Client
|
|
10
|
+
def initialize(config)
|
|
11
|
+
@config = config
|
|
12
|
+
@base_url = config.api_url.chomp('/')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# GET /workspaces (called "projects" in the CLI)
|
|
16
|
+
def list_projects
|
|
17
|
+
get('/api/workspaces')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# GET /workspaces/:id
|
|
21
|
+
def get_project(id)
|
|
22
|
+
get("/api/workspaces/#{id}")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# GET /requirements
|
|
26
|
+
def list_requirements(workspace_id: nil, status: nil)
|
|
27
|
+
params = {}
|
|
28
|
+
params[:workspace_id] = workspace_id if workspace_id
|
|
29
|
+
params[:status] = status if status
|
|
30
|
+
get('/api/requirements', params)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# GET /requirements/:id
|
|
34
|
+
def get_requirement(id)
|
|
35
|
+
get("/api/requirements/#{id}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# GET /requirements by slug within a workspace
|
|
39
|
+
def find_requirement_by_slug(workspace_id, slug)
|
|
40
|
+
# List all requirements for the workspace and find by slug
|
|
41
|
+
result = list_requirements(workspace_id: workspace_id)
|
|
42
|
+
return result unless result[:ok]
|
|
43
|
+
|
|
44
|
+
requirements = result[:data]['requirements'] || []
|
|
45
|
+
requirement = requirements.find { |r| r['slug'] == slug }
|
|
46
|
+
|
|
47
|
+
if requirement
|
|
48
|
+
{ ok: true, data: { 'requirement' => requirement } }
|
|
49
|
+
else
|
|
50
|
+
{ ok: false, error: "Requirement '#{slug}' not found in workspace", status: 404 }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# POST /requirements
|
|
55
|
+
def create_requirement(params)
|
|
56
|
+
post('/api/requirements', params)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# PUT /requirements/:id
|
|
60
|
+
def update_requirement(id, params)
|
|
61
|
+
put("/api/requirements/#{id}", params)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# POST /evidence (when API is ready)
|
|
65
|
+
def report_evidence(params)
|
|
66
|
+
post('/api/evidence', params)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# GET /matrix (when API is ready)
|
|
70
|
+
def get_matrix(workspace_id, format: :json)
|
|
71
|
+
path = format == :csv ? '/api/matrix.csv' : '/api/matrix'
|
|
72
|
+
get(path, { workspace_id: workspace_id })
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# GET /gaps (when API is ready)
|
|
76
|
+
def get_gaps(workspace_id, surface: nil)
|
|
77
|
+
params = { workspace_id: workspace_id }
|
|
78
|
+
params[:surface] = surface if surface
|
|
79
|
+
get('/api/gaps', params)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def get(path, params = {})
|
|
85
|
+
uri = build_uri(path, params)
|
|
86
|
+
request = Net::HTTP::Get.new(uri)
|
|
87
|
+
execute(uri, request)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def post(path, body)
|
|
91
|
+
uri = build_uri(path)
|
|
92
|
+
request = Net::HTTP::Post.new(uri)
|
|
93
|
+
request.body = body.to_json
|
|
94
|
+
request['Content-Type'] = 'application/json'
|
|
95
|
+
execute(uri, request)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def put(path, body)
|
|
99
|
+
uri = build_uri(path)
|
|
100
|
+
request = Net::HTTP::Put.new(uri)
|
|
101
|
+
request.body = body.to_json
|
|
102
|
+
request['Content-Type'] = 'application/json'
|
|
103
|
+
execute(uri, request)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def build_uri(path, params = {})
|
|
107
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
108
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
109
|
+
uri
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def execute(uri, request)
|
|
113
|
+
request['Authorization'] = "Bearer #{@config.api_key}"
|
|
114
|
+
request['Accept'] = 'application/json'
|
|
115
|
+
request['User-Agent'] = "fp-cli/#{Fp::VERSION}"
|
|
116
|
+
|
|
117
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
118
|
+
http.use_ssl = uri.scheme == 'https'
|
|
119
|
+
http.open_timeout = 10
|
|
120
|
+
http.read_timeout = 30
|
|
121
|
+
|
|
122
|
+
response = http.request(request)
|
|
123
|
+
parse_response(response)
|
|
124
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
125
|
+
{ ok: false, error: "Request timed out: #{e.message}", status: 0 }
|
|
126
|
+
rescue SocketError, Errno::ECONNREFUSED => e
|
|
127
|
+
{ ok: false, error: "Connection failed: #{e.message}", status: 0 }
|
|
128
|
+
rescue StandardError => e
|
|
129
|
+
{ ok: false, error: "Request failed: #{e.message}", status: 0 }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_response(response)
|
|
133
|
+
body = response.body
|
|
134
|
+
status = response.code.to_i
|
|
135
|
+
|
|
136
|
+
# Try to parse as JSON
|
|
137
|
+
data = JSON.parse(body) rescue nil
|
|
138
|
+
|
|
139
|
+
if status >= 200 && status < 300
|
|
140
|
+
{ ok: true, data: data, status: status }
|
|
141
|
+
else
|
|
142
|
+
error_message = if data.is_a?(Hash)
|
|
143
|
+
data['error'] || data['message'] || "HTTP #{status}"
|
|
144
|
+
else
|
|
145
|
+
body.to_s.empty? ? "HTTP #{status}" : body.to_s[0..200]
|
|
146
|
+
end
|
|
147
|
+
{ ok: false, error: error_message, status: status }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# Base class for all commands
|
|
6
|
+
class Base
|
|
7
|
+
def initialize(client: nil, output: nil, config: nil)
|
|
8
|
+
@client = client
|
|
9
|
+
@output = output
|
|
10
|
+
@config = config
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(_args)
|
|
14
|
+
raise NotImplementedError, "#{self.class}#run must be implemented"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
protected
|
|
18
|
+
|
|
19
|
+
attr_reader :client, :output, :config
|
|
20
|
+
|
|
21
|
+
# Parse command-line flags into a hash
|
|
22
|
+
# Supports: --flag value, --flag=value, --boolean-flag
|
|
23
|
+
def parse_flags(args, known_flags = {})
|
|
24
|
+
opts = {}
|
|
25
|
+
positional = []
|
|
26
|
+
i = 0
|
|
27
|
+
|
|
28
|
+
while i < args.length
|
|
29
|
+
arg = args[i]
|
|
30
|
+
|
|
31
|
+
if arg.start_with?('--')
|
|
32
|
+
if arg.include?('=')
|
|
33
|
+
key, value = arg[2..].split('=', 2)
|
|
34
|
+
opts[key.tr('-', '_').to_sym] = value
|
|
35
|
+
elsif known_flags[arg[2..].tr('-', '_').to_sym] == :boolean
|
|
36
|
+
opts[arg[2..].tr('-', '_').to_sym] = true
|
|
37
|
+
elsif i + 1 < args.length && !args[i + 1].start_with?('--')
|
|
38
|
+
opts[arg[2..].tr('-', '_').to_sym] = args[i + 1]
|
|
39
|
+
i += 1
|
|
40
|
+
else
|
|
41
|
+
opts[arg[2..].tr('-', '_').to_sym] = true
|
|
42
|
+
end
|
|
43
|
+
else
|
|
44
|
+
positional << arg
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
i += 1
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
[opts, positional]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Resolve workspace ID from --project flag (can be slug or ID)
|
|
54
|
+
def resolve_workspace_id(project_slug_or_id)
|
|
55
|
+
return nil unless project_slug_or_id
|
|
56
|
+
|
|
57
|
+
# First try to find by listing workspaces
|
|
58
|
+
result = client.list_projects
|
|
59
|
+
unless result[:ok]
|
|
60
|
+
output.error(result[:error], status: result[:status])
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
workspaces = result[:data]['workspaces'] || []
|
|
65
|
+
workspace = workspaces.find { |w| w['slug'] == project_slug_or_id || w['id'] == project_slug_or_id }
|
|
66
|
+
|
|
67
|
+
unless workspace
|
|
68
|
+
output.error("Project '#{project_slug_or_id}' not found")
|
|
69
|
+
exit 1
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
workspace['id']
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def require_flag(opts, flag, message = nil)
|
|
76
|
+
unless opts[flag]
|
|
77
|
+
message ||= "--#{flag.to_s.tr('_', '-')} is required"
|
|
78
|
+
output.error(message)
|
|
79
|
+
exit 1
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def truncate(str, max)
|
|
84
|
+
return str if str.nil? || str.length <= max
|
|
85
|
+
|
|
86
|
+
str[0...max - 3] + '...'
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp help
|
|
6
|
+
class Help < Base
|
|
7
|
+
def run(_args)
|
|
8
|
+
puts <<~HELP
|
|
9
|
+
fp - FeatureParity CLI for agents
|
|
10
|
+
|
|
11
|
+
USAGE
|
|
12
|
+
fp <command> [flags]
|
|
13
|
+
|
|
14
|
+
COMMANDS
|
|
15
|
+
projects List projects you have access to
|
|
16
|
+
surfaces --project <slug> List surfaces for a project
|
|
17
|
+
list --project <slug> List active requirements
|
|
18
|
+
show <slug> --project <slug> Show requirement details
|
|
19
|
+
propose --project <slug> ... Propose a new requirement (as draft)
|
|
20
|
+
report <slug> --project <slug>... Report evidence for a requirement
|
|
21
|
+
matrix --project <slug> Show the parity matrix
|
|
22
|
+
profile add|list Manage named profiles
|
|
23
|
+
setup Interactive first-time setup
|
|
24
|
+
version Show version
|
|
25
|
+
help Show this help
|
|
26
|
+
|
|
27
|
+
GLOBAL FLAGS
|
|
28
|
+
--json Output as JSON envelope
|
|
29
|
+
--profile <name> Use a named profile
|
|
30
|
+
-p <name> Alias for --profile
|
|
31
|
+
|
|
32
|
+
AUTHENTICATION
|
|
33
|
+
Set FP_API_KEY environment variable, or use a profile:
|
|
34
|
+
|
|
35
|
+
export FP_API_KEY=fp_...
|
|
36
|
+
fp projects
|
|
37
|
+
|
|
38
|
+
Or run interactive setup (validates key and saves profile):
|
|
39
|
+
|
|
40
|
+
fp setup
|
|
41
|
+
fp setup --api-key fp_... --non-interactive
|
|
42
|
+
|
|
43
|
+
Or add a named profile manually:
|
|
44
|
+
|
|
45
|
+
fp profile add galen --api-key fp_...
|
|
46
|
+
fp --profile galen projects
|
|
47
|
+
FP_PROFILE=galen fp projects
|
|
48
|
+
|
|
49
|
+
If both FP_API_KEY and a profile are set, FP_API_KEY wins.
|
|
50
|
+
|
|
51
|
+
EXAMPLES
|
|
52
|
+
# First-time setup (interactive)
|
|
53
|
+
fp setup
|
|
54
|
+
|
|
55
|
+
# Non-interactive setup for CI/automation
|
|
56
|
+
fp setup --api-key fp_... --profile ci-agent --non-interactive
|
|
57
|
+
|
|
58
|
+
# List projects
|
|
59
|
+
fp projects
|
|
60
|
+
|
|
61
|
+
# List requirements for a project
|
|
62
|
+
fp list --project stowzilla
|
|
63
|
+
|
|
64
|
+
# Show gaps (requirements without evidence)
|
|
65
|
+
fp list --project stowzilla --gaps --surface customer_android
|
|
66
|
+
|
|
67
|
+
# Show a specific requirement
|
|
68
|
+
fp show print_container_qr --project stowzilla
|
|
69
|
+
|
|
70
|
+
# Propose a new requirement (creates as draft)
|
|
71
|
+
fp propose --project stowzilla \\
|
|
72
|
+
--slug print_container_qr \\
|
|
73
|
+
--name "Print QR code on container label" \\
|
|
74
|
+
--why "Customers scan to track containers" \\
|
|
75
|
+
--required api,customer_android,customer_ios \\
|
|
76
|
+
--acceptance "QR code is printed and scannable"
|
|
77
|
+
|
|
78
|
+
# Report evidence for a requirement
|
|
79
|
+
fp report print_container_qr --project stowzilla \\
|
|
80
|
+
--surface customer_android \\
|
|
81
|
+
--file app/src/test/java/PrintQrTest.kt \\
|
|
82
|
+
--repo stowzilla/customer-android \\
|
|
83
|
+
--pr https://github.com/stowzilla/customer-android/pull/123 \\
|
|
84
|
+
--work-item https://app.fizzy.do/123/cards/456 \\
|
|
85
|
+
--title "prints a QR code onto the container label"
|
|
86
|
+
|
|
87
|
+
# Show the parity matrix
|
|
88
|
+
fp matrix --project stowzilla
|
|
89
|
+
|
|
90
|
+
# Export matrix as CSV
|
|
91
|
+
fp matrix --project stowzilla --csv > matrix.csv
|
|
92
|
+
|
|
93
|
+
MARKER CONVENTION
|
|
94
|
+
Tests are bound to requirements using the fp:<slug> marker in the test file.
|
|
95
|
+
The CLI does not rewrite test files - you add the marker yourself.
|
|
96
|
+
|
|
97
|
+
Example (Ruby/RSpec):
|
|
98
|
+
# fp:print_container_qr
|
|
99
|
+
it 'prints a QR code onto the container label' do
|
|
100
|
+
...
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
Example (Kotlin):
|
|
104
|
+
// fp:print_container_qr
|
|
105
|
+
@Test
|
|
106
|
+
fun `prints a QR code onto the container label`() {
|
|
107
|
+
...
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
Example (Swift/XCTest):
|
|
111
|
+
// fp:print_container_qr
|
|
112
|
+
func testPrintsQRCodeOntoContainerLabel() {
|
|
113
|
+
...
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
MORE INFORMATION
|
|
117
|
+
https://featureparity.dev/docs
|
|
118
|
+
HELP
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp list --project <slug> [--surface X] [--gaps] [--status draft]
|
|
6
|
+
# List requirements, optionally filtered
|
|
7
|
+
class List < Base
|
|
8
|
+
KNOWN_FLAGS = {
|
|
9
|
+
project: :string,
|
|
10
|
+
surface: :string,
|
|
11
|
+
status: :string,
|
|
12
|
+
gaps: :boolean
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def run(args)
|
|
16
|
+
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
17
|
+
require_flag(opts, :project)
|
|
18
|
+
|
|
19
|
+
workspace_id = resolve_workspace_id(opts[:project])
|
|
20
|
+
|
|
21
|
+
if opts[:gaps]
|
|
22
|
+
list_gaps(workspace_id, opts)
|
|
23
|
+
else
|
|
24
|
+
list_requirements(workspace_id, opts)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def list_requirements(workspace_id, opts)
|
|
31
|
+
result = client.list_requirements(workspace_id: workspace_id)
|
|
32
|
+
|
|
33
|
+
unless result[:ok]
|
|
34
|
+
output.error(result[:error], status: result[:status])
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
requirements = result[:data]['requirements'] || []
|
|
39
|
+
|
|
40
|
+
# Filter by status (default: active only)
|
|
41
|
+
status_filter = opts[:status] || 'active'
|
|
42
|
+
requirements = requirements.select { |r| r['status'] == status_filter }
|
|
43
|
+
|
|
44
|
+
# Filter by surface if specified
|
|
45
|
+
if opts[:surface]
|
|
46
|
+
requirements = requirements.select do |r|
|
|
47
|
+
(r['required_surfaces'] || []).include?(opts[:surface])
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
output.success({ requirements: requirements, project: opts[:project] }) do
|
|
52
|
+
if requirements.empty?
|
|
53
|
+
puts "No #{status_filter} requirements found."
|
|
54
|
+
else
|
|
55
|
+
headers = %w[SLUG TITLE SURFACES STATUS]
|
|
56
|
+
rows = requirements.map do |r|
|
|
57
|
+
surfaces = (r['required_surfaces'] || []).join(', ')
|
|
58
|
+
[r['slug'], truncate(r['title'], 40), surfaces.empty? ? '-' : surfaces, r['status']]
|
|
59
|
+
end
|
|
60
|
+
output.table(headers, rows)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def list_gaps(workspace_id, opts)
|
|
66
|
+
# Gaps endpoint may not exist yet - fall back to computing from requirements
|
|
67
|
+
# For now, list requirements that don't have evidence for a surface
|
|
68
|
+
result = client.list_requirements(workspace_id: workspace_id)
|
|
69
|
+
|
|
70
|
+
unless result[:ok]
|
|
71
|
+
output.error(result[:error], status: result[:status])
|
|
72
|
+
exit 1
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Only show active requirements for gaps
|
|
76
|
+
requirements = (result[:data]['requirements'] || []).select { |r| r['status'] == 'active' }
|
|
77
|
+
|
|
78
|
+
# Filter by surface if specified
|
|
79
|
+
if opts[:surface]
|
|
80
|
+
requirements = requirements.select do |r|
|
|
81
|
+
(r['required_surfaces'] || []).include?(opts[:surface])
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# For now, gaps = all requirements (evidence API is #1214)
|
|
86
|
+
# Once evidence API exists, filter to only those without evidence
|
|
87
|
+
output.success({ gaps: requirements, project: opts[:project], surface: opts[:surface] }) do
|
|
88
|
+
if requirements.empty?
|
|
89
|
+
puts 'No gaps found.'
|
|
90
|
+
else
|
|
91
|
+
puts "Gaps for project '#{opts[:project]}'" + (opts[:surface] ? " (surface: #{opts[:surface]})" : '') + ':'
|
|
92
|
+
headers = %w[SLUG TITLE SURFACES]
|
|
93
|
+
rows = requirements.map do |r|
|
|
94
|
+
surfaces = (r['required_surfaces'] || []).join(', ')
|
|
95
|
+
[r['slug'], truncate(r['title'], 40), surfaces]
|
|
96
|
+
end
|
|
97
|
+
output.table(headers, rows)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp matrix --project <slug> [--csv]
|
|
6
|
+
# Show the requirements traceability matrix
|
|
7
|
+
class Matrix < Base
|
|
8
|
+
KNOWN_FLAGS = {
|
|
9
|
+
project: :string,
|
|
10
|
+
csv: :boolean
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def run(args)
|
|
14
|
+
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
15
|
+
require_flag(opts, :project)
|
|
16
|
+
|
|
17
|
+
workspace_id = resolve_workspace_id(opts[:project])
|
|
18
|
+
|
|
19
|
+
# Get workspace for surfaces
|
|
20
|
+
ws_result = client.get_project(workspace_id)
|
|
21
|
+
unless ws_result[:ok]
|
|
22
|
+
output.error(ws_result[:error], status: ws_result[:status])
|
|
23
|
+
exit 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
workspace = ws_result[:data]['workspace']
|
|
27
|
+
surfaces = workspace['available_surfaces'] || []
|
|
28
|
+
|
|
29
|
+
# Get active requirements
|
|
30
|
+
req_result = client.list_requirements(workspace_id: workspace_id)
|
|
31
|
+
unless req_result[:ok]
|
|
32
|
+
output.error(req_result[:error], status: req_result[:status])
|
|
33
|
+
exit 1
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
requirements = (req_result[:data]['requirements'] || []).select { |r| r['status'] == 'active' }
|
|
37
|
+
|
|
38
|
+
if requirements.empty?
|
|
39
|
+
output.success({ matrix: [], surfaces: surfaces }) do
|
|
40
|
+
puts 'No active requirements. The matrix is empty.'
|
|
41
|
+
end
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Build matrix data
|
|
46
|
+
# Columns: SLUG, TITLE, then one column per surface
|
|
47
|
+
headers = %w[SLUG TITLE] + surfaces
|
|
48
|
+
|
|
49
|
+
rows = requirements.map do |req|
|
|
50
|
+
required = req['required_surfaces'] || []
|
|
51
|
+
# Evidence status per surface (placeholder until Evidence API exists)
|
|
52
|
+
surface_cells = surfaces.map do |s|
|
|
53
|
+
if required.include?(s)
|
|
54
|
+
'⬜' # Required but no evidence yet
|
|
55
|
+
else
|
|
56
|
+
'-' # Not required
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
[req['slug'], truncate(req['title'], 30)] + surface_cells
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if opts[:csv]
|
|
63
|
+
output.csv(headers, rows)
|
|
64
|
+
else
|
|
65
|
+
output.success({ requirements: requirements, surfaces: surfaces, project: opts[:project] }) do
|
|
66
|
+
puts "Parity Matrix: #{opts[:project]}"
|
|
67
|
+
puts
|
|
68
|
+
output.table(headers, rows)
|
|
69
|
+
puts
|
|
70
|
+
puts 'Legend: ✅ = passing, ⬜ = required (no evidence), - = not required'
|
|
71
|
+
puts
|
|
72
|
+
puts '(Evidence status will show when #1214 is complete)'
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|