featureparity 0.0.2 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7fec4ef9bda94cba09b19e5209ed867ff87d6a5312c6c6441234691e406bd2a2
4
- data.tar.gz: 7fd7a58df29f9a69ce342ed60e5b7ff60b796f286404b81a56108be1f5c83f1b
3
+ metadata.gz: 166fe16ad8636be840cc889d1104ad4e6a5f3c84cd046f0a94f291412083f344
4
+ data.tar.gz: e88add572a4cfd638768ae4d65de5a207b6a64c450ca0fdce426f48e8e51427e
5
5
  SHA512:
6
- metadata.gz: b198114877fb8ca6695f14c38796334b2cf4269e4164509a97a7b0d12cf4fa7f86ecbd4036028fad15fb80eb00d95da5a410542eda6cdcbd7366b33e6c0dab11
7
- data.tar.gz: 7a6a248d554a4fa13bc5b33591be0868f2714dfc483c81da0ceafaa4fa951f5f915c229ac8096fbda59efae8319c39e135e4b36e4403944c6ceec80cc7f74832
6
+ metadata.gz: c3d5bee44367c02c8329c10bdb3c0615978c405c30d46fb002b1ca7fe6676a61075dc83bf4ba02ba01faf828d80fa7f56af2116fd5287f662166bf547d9c5468
7
+ data.tar.gz: 5b627165e1205cca331626fd46b35d7ad71b9ed666fd63ebe96569b2e2989b4b14ff35426d099a1e7dfd8afe7ac16c7984e53ec1052262a374914505c950404c
data/lib/fp/cli.rb CHANGED
@@ -13,6 +13,7 @@ module Fp
13
13
  'matrix' => Commands::Matrix,
14
14
  'profile' => Commands::Profile,
15
15
  'config' => Commands::ConfigCmd,
16
+ 'repos' => Commands::Repos,
16
17
  'setup' => Commands::Setup,
17
18
  'version' => Commands::Version,
18
19
  'help' => Commands::Help
@@ -34,7 +35,7 @@ module Fp
34
35
  end
35
36
 
36
37
  # Profile/config commands don't need auth
37
- needs_auth = !%w[profile config setup version help].include?(command_name)
38
+ needs_auth = !%w[profile config repos setup version help].include?(command_name)
38
39
 
39
40
  if needs_auth
40
41
  config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
data/lib/fp/client.rb CHANGED
@@ -12,21 +12,27 @@ module Fp
12
12
  @base_url = config.api_url.chomp('/')
13
13
  end
14
14
 
15
- # GET /workspaces (called "projects" in the CLI)
15
+ # GET /projects
16
16
  def list_projects
17
- get('/api/workspaces')
17
+ get('/api/projects')
18
18
  end
19
19
 
20
- # GET /workspaces/:id
20
+ # GET /projects/:id
21
21
  def get_project(id)
22
- get("/api/workspaces/#{id}")
22
+ get("/api/projects/#{id}")
23
+ end
24
+
25
+ # GET /projects/:id/surfaces
26
+ def list_surfaces(project_id)
27
+ get("/api/projects/#{project_id}/surfaces")
23
28
  end
24
29
 
25
30
  # GET /requirements
26
- def list_requirements(workspace_id: nil, status: nil)
31
+ def list_requirements(project_id: nil, status: nil, category: nil)
27
32
  params = {}
28
- params[:workspace_id] = workspace_id if workspace_id
33
+ params[:project_id] = project_id if project_id
29
34
  params[:status] = status if status
35
+ params[:category] = category if category
30
36
  get('/api/requirements', params)
31
37
  end
32
38
 
@@ -35,10 +41,10 @@ module Fp
35
41
  get("/api/requirements/#{id}")
36
42
  end
37
43
 
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)
44
+ # GET /requirements by slug within a project
45
+ def find_requirement_by_slug(project_id, slug)
46
+ # List all requirements for the project and find by slug
47
+ result = list_requirements(project_id: project_id)
42
48
  return result unless result[:ok]
43
49
 
44
50
  requirements = result[:data]['requirements'] || []
@@ -47,7 +53,7 @@ module Fp
47
53
  if requirement
48
54
  { ok: true, data: { 'requirement' => requirement } }
49
55
  else
50
- { ok: false, error: "Requirement '#{slug}' not found in workspace", status: 404 }
56
+ { ok: false, error: "Requirement '#{slug}' not found in project", status: 404 }
51
57
  end
52
58
  end
53
59
 
@@ -61,20 +67,21 @@ module Fp
61
67
  put("/api/requirements/#{id}", params)
62
68
  end
63
69
 
64
- # POST /evidence (when API is ready)
70
+ # POST /projects/:project_id/evidence (direct reporting)
65
71
  def report_evidence(params)
66
- post('/api/evidence', params)
72
+ project_id = params.delete(:project_id)
73
+ post("/api/projects/#{project_id}/evidence", params)
67
74
  end
68
75
 
69
76
  # GET /matrix (when API is ready)
70
- def get_matrix(workspace_id, format: :json)
77
+ def get_matrix(project_id, format: :json)
71
78
  path = format == :csv ? '/api/matrix.csv' : '/api/matrix'
72
- get(path, { workspace_id: workspace_id })
79
+ get(path, { project_id: project_id })
73
80
  end
74
81
 
75
82
  # GET /gaps (when API is ready)
76
- def get_gaps(workspace_id, surface: nil)
77
- params = { workspace_id: workspace_id }
83
+ def get_gaps(project_id, surface: nil)
84
+ params = { project_id: project_id }
78
85
  params[:surface] = surface if surface
79
86
  get('/api/gaps', params)
80
87
  end
@@ -50,26 +50,26 @@ module Fp
50
50
  [opts, positional]
51
51
  end
52
52
 
53
- # Resolve workspace ID from --project flag (can be slug or ID)
54
- def resolve_workspace_id(project_slug_or_id)
53
+ # Resolve project ID from --project flag (can be slug or ID)
54
+ def resolve_project_id(project_slug_or_id)
55
55
  return nil unless project_slug_or_id
56
56
 
57
- # First try to find by listing workspaces
57
+ # First try to find by listing projects
58
58
  result = client.list_projects
59
59
  unless result[:ok]
60
60
  output.error(result[:error], status: result[:status])
61
61
  exit 1
62
62
  end
63
63
 
64
- workspaces = result[:data]['workspaces'] || []
65
- workspace = workspaces.find { |w| w['slug'] == project_slug_or_id || w['id'] == project_slug_or_id }
64
+ projects = result[:data]['projects'] || []
65
+ project = projects.find { |w| w['slug'] == project_slug_or_id || w['id'] == project_slug_or_id }
66
66
 
67
- unless workspace
67
+ unless project
68
68
  output.error("Project '#{project_slug_or_id}' not found")
69
69
  exit 1
70
70
  end
71
71
 
72
- workspace['id']
72
+ project['id']
73
73
  end
74
74
 
75
75
  def require_flag(opts, flag, message = nil)
@@ -18,9 +18,11 @@ module Fp
18
18
  show <slug> --project <slug> Show requirement details
19
19
  propose --project <slug> ... Propose a new requirement (as draft)
20
20
  report <slug> --project <slug>... Report evidence for a requirement
21
+ report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
21
22
  matrix --project <slug> Show the parity matrix
22
23
  profile add|list Manage named profiles
23
24
  config set|get|unset|list Manage global settings
25
+ repos set|get|list|unset Map surfaces to local repo paths
24
26
  setup Interactive first-time setup
25
27
  version Show version
26
28
  help Show this help
@@ -63,6 +65,9 @@ module Fp
63
65
  # List requirements for a project
64
66
  fp list --project stowzilla
65
67
 
68
+ # List requirements filtered by category
69
+ fp list --project stowzilla --category security
70
+
66
71
  # Show gaps (requirements without evidence)
67
72
  fp list --project stowzilla --gaps --surface customer_android
68
73
 
@@ -75,7 +80,8 @@ module Fp
75
80
  --name "Print QR code on container label" \\
76
81
  --why "Customers scan to track containers" \\
77
82
  --required api,customer_android,customer_ios \\
78
- --acceptance "QR code is printed and scannable"
83
+ --acceptance "QR code is printed and scannable" \\
84
+ --category functional
79
85
 
80
86
  # Report evidence for a requirement
81
87
  fp report print_container_qr --project stowzilla \\
@@ -86,12 +92,31 @@ module Fp
86
92
  --work-item https://app.fizzy.do/123/cards/456 \\
87
93
  --title "prints a QR code onto the container label"
88
94
 
95
+ # Upload evidence without CI: run your suite, emit JUnit XML, upload it.
96
+ # fp parses the report, finds the fp:<slug> markers in the test files,
97
+ # and reports evidence for every requirement the suite covered.
98
+ # rspec --format RspecJunitFormatter --out junit.xml # (or your runner's equivalent)
99
+ fp report --junit junit.xml --project stowzilla \\
100
+ --surface api \\
101
+ --repo stowzilla/marketplace \\
102
+ --work-item https://app.fizzy.do/123/cards/456
103
+ # --base-dir sets where relative test file paths in the report resolve from
104
+ # Skipped tests are reported as 'stub'; everything else as 'present'.
105
+
89
106
  # Show the parity matrix
90
107
  fp matrix --project stowzilla
91
108
 
92
109
  # Export matrix as CSV
93
110
  fp matrix --project stowzilla --csv > matrix.csv
94
111
 
112
+ # Map a surface to a local repo on this machine
113
+ fp repos set customer_android ~/code/customer-android \\
114
+ --project stowzilla --repo stowzilla/customer-android
115
+
116
+ # Find where a surface's code lives locally (for agents)
117
+ fp repos get customer_android --project stowzilla
118
+ cd "$(fp repos get customer_android --project stowzilla)"
119
+
95
120
  MARKER CONVENTION
96
121
  Tests are bound to requirements using the fp:<slug> marker in the test file.
97
122
  The CLI does not rewrite test files - you add the marker yourself.
@@ -115,6 +140,18 @@ module Fp
115
140
  ...
116
141
  }
117
142
 
143
+ PINNING SURFACES IN THE MARKER
144
+ A marker may target one or more surfaces with an @suffix. This is
145
+ handy when a single (e.g. backend) test satisfies several surfaces:
146
+
147
+ # fp:print_container_qr -> uses the --surface flag
148
+ # fp:print_container_qr@api -> reports for the api surface
149
+ # fp:print_container_qr@api,web -> reports for api AND web
150
+
151
+ With `fp report --junit`, each pinned surface produces its own
152
+ evidence upload. Markers without an @surface fall back to --surface;
153
+ if a marker pins none and no --surface is given, the run errors.
154
+
118
155
  MORE INFORMATION
119
156
  https://featureparity.dev/docs
120
157
  HELP
@@ -2,12 +2,13 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp list --project <slug> [--surface X] [--gaps] [--status draft]
5
+ # fp list --project <slug> [--surface X] [--category Y] [--gaps] [--status draft]
6
6
  # List requirements, optionally filtered
7
7
  class List < Base
8
8
  KNOWN_FLAGS = {
9
9
  project: :string,
10
10
  surface: :string,
11
+ category: :string,
11
12
  status: :string,
12
13
  gaps: :boolean
13
14
  }.freeze
@@ -16,19 +17,19 @@ module Fp
16
17
  opts, = parse_flags(args, KNOWN_FLAGS)
17
18
  require_flag(opts, :project)
18
19
 
19
- workspace_id = resolve_workspace_id(opts[:project])
20
+ project_id = resolve_project_id(opts[:project])
20
21
 
21
22
  if opts[:gaps]
22
- list_gaps(workspace_id, opts)
23
+ list_gaps(project_id, opts)
23
24
  else
24
- list_requirements(workspace_id, opts)
25
+ list_requirements(project_id, opts)
25
26
  end
26
27
  end
27
28
 
28
29
  private
29
30
 
30
- def list_requirements(workspace_id, opts)
31
- result = client.list_requirements(workspace_id: workspace_id)
31
+ def list_requirements(project_id, opts)
32
+ result = client.list_requirements(project_id: project_id, category: opts[:category])
32
33
 
33
34
  unless result[:ok]
34
35
  output.error(result[:error], status: result[:status])
@@ -50,7 +51,10 @@ module Fp
50
51
 
51
52
  output.success({ requirements: requirements, project: opts[:project] }) do
52
53
  if requirements.empty?
53
- puts "No #{status_filter} requirements found."
54
+ filter_desc = [status_filter]
55
+ filter_desc << "category: #{opts[:category]}" if opts[:category]
56
+ filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
57
+ puts "No requirements found (#{filter_desc.join(', ')})."
54
58
  else
55
59
  # Group into tree structure: parents first, then children indented
56
60
  parents = requirements.select { |r| r['parent_id'].nil? || r['parent_id'] == '' }
@@ -62,24 +66,27 @@ module Fp
62
66
  parent_ids = parents.map { |p| p['id'] }.to_set
63
67
  orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
64
68
 
65
- headers = %w[SLUG TITLE SURFACES STATUS]
69
+ headers = %w[SLUG TITLE CATEGORY SURFACES STATUS]
66
70
  rows = []
67
71
 
68
72
  parents.each do |r|
69
73
  surfaces = (r['required_surfaces'] || []).join(', ')
70
- rows << [r['slug'], truncate(r['title'], 40), surfaces.empty? ? '-' : surfaces, r['status']]
74
+ category = r['category'] || '-'
75
+ rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
71
76
 
72
77
  # Add children indented
73
78
  (children_by_parent[r['id']] || []).each do |child|
74
79
  child_surfaces = (child['required_surfaces'] || []).join(', ')
75
- rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_surfaces.empty? ? '-' : child_surfaces, child['status']]
80
+ child_category = child['category'] || '-'
81
+ rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_surfaces.empty? ? '-' : child_surfaces, child['status']]
76
82
  end
77
83
  end
78
84
 
79
85
  # Show orphans at top level
80
86
  orphans.each do |r|
81
87
  surfaces = (r['required_surfaces'] || []).join(', ')
82
- rows << [r['slug'], truncate(r['title'], 40), surfaces.empty? ? '-' : surfaces, r['status']]
88
+ category = r['category'] || '-'
89
+ rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
83
90
  end
84
91
 
85
92
  output.table(headers, rows)
@@ -87,10 +94,10 @@ module Fp
87
94
  end
88
95
  end
89
96
 
90
- def list_gaps(workspace_id, opts)
97
+ def list_gaps(project_id, opts)
91
98
  # Gaps endpoint may not exist yet - fall back to computing from requirements
92
99
  # For now, list requirements that don't have evidence for a surface
93
- result = client.list_requirements(workspace_id: workspace_id)
100
+ result = client.list_requirements(project_id: project_id, category: opts[:category])
94
101
 
95
102
  unless result[:ok]
96
103
  output.error(result[:error], status: result[:status])
@@ -109,15 +116,24 @@ module Fp
109
116
 
110
117
  # For now, gaps = all requirements (evidence API is #1214)
111
118
  # Once evidence API exists, filter to only those without evidence
112
- output.success({ gaps: requirements, project: opts[:project], surface: opts[:surface] }) do
119
+ output.success({ gaps: requirements, project: opts[:project], surface: opts[:surface], category: opts[:category] }) do
113
120
  if requirements.empty?
114
- puts 'No gaps found.'
121
+ filter_desc = []
122
+ filter_desc << "category: #{opts[:category]}" if opts[:category]
123
+ filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
124
+ msg = 'No gaps found'
125
+ msg += " (#{filter_desc.join(', ')})" unless filter_desc.empty?
126
+ puts "#{msg}."
115
127
  else
116
- puts "Gaps for project '#{opts[:project]}'" + (opts[:surface] ? " (surface: #{opts[:surface]})" : '') + ':'
117
- headers = %w[SLUG TITLE SURFACES]
128
+ desc = "Gaps for project '#{opts[:project]}'"
129
+ desc += " (surface: #{opts[:surface]})" if opts[:surface]
130
+ desc += " (category: #{opts[:category]})" if opts[:category]
131
+ puts "#{desc}:"
132
+ headers = %w[SLUG TITLE CATEGORY SURFACES]
118
133
  rows = requirements.map do |r|
119
134
  surfaces = (r['required_surfaces'] || []).join(', ')
120
- [r['slug'], truncate(r['title'], 40), surfaces]
135
+ category = r['category'] || '-'
136
+ [r['slug'], truncate(r['title'], 40), category, surfaces]
121
137
  end
122
138
  output.table(headers, rows)
123
139
  end
@@ -14,20 +14,19 @@ module Fp
14
14
  opts, = parse_flags(args, KNOWN_FLAGS)
15
15
  require_flag(opts, :project)
16
16
 
17
- workspace_id = resolve_workspace_id(opts[:project])
17
+ project_id = resolve_project_id(opts[:project])
18
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])
19
+ # Get surfaces for the project
20
+ surfaces_result = client.list_surfaces(project_id)
21
+ unless surfaces_result[:ok]
22
+ output.error(surfaces_result[:error], status: surfaces_result[:status])
23
23
  exit 1
24
24
  end
25
25
 
26
- workspace = ws_result[:data]['workspace']
27
- surfaces = workspace['available_surfaces'] || []
26
+ surfaces = (surfaces_result[:data]['surfaces'] || []).map { |s| s['key'] }
28
27
 
29
28
  # Get active requirements
30
- req_result = client.list_requirements(workspace_id: workspace_id)
29
+ req_result = client.list_requirements(project_id: project_id)
31
30
  unless req_result[:ok]
32
31
  output.error(req_result[:error], status: req_result[:status])
33
32
  exit 1
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp projects - list projects/workspaces the API key has access to
5
+ # fp projects - list projects the API key has access to
6
6
  class Projects < Base
7
7
  def run(_args)
8
8
  result = client.list_projects
@@ -12,15 +12,21 @@ module Fp
12
12
  exit 1
13
13
  end
14
14
 
15
- workspaces = result[:data]['workspaces'] || []
15
+ projects = result[:data]['projects'] || []
16
16
 
17
17
  output.success(result[:data]) do
18
- if workspaces.empty?
18
+ if projects.empty?
19
19
  puts 'No projects found.'
20
20
  else
21
21
  headers = %w[SLUG NAME SURFACES]
22
- rows = workspaces.map do |w|
23
- surfaces = (w['available_surfaces'] || []).join(', ')
22
+ rows = projects.map do |w|
23
+ surfaces_result = client.list_surfaces(w['id'])
24
+ surface_keys = if surfaces_result[:ok]
25
+ (surfaces_result[:data]['surfaces'] || []).map { |s| s['key'] }
26
+ else
27
+ []
28
+ end
29
+ surfaces = surface_keys.join(', ')
24
30
  [w['slug'], w['name'], surfaces.empty? ? '-' : surfaces]
25
31
  end
26
32
  output.table(headers, rows)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --acceptance "..." --parent <slug>
5
+ # fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --acceptance "..." --parent <slug> --category <cat>
6
6
  # Create a new requirement as draft
7
7
  class Propose < Base
8
8
  KNOWN_FLAGS = {
@@ -12,9 +12,12 @@ module Fp
12
12
  why: :string,
13
13
  required: :string,
14
14
  acceptance: :string,
15
- parent: :string
15
+ parent: :string,
16
+ category: :string
16
17
  }.freeze
17
18
 
19
+ VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
20
+
18
21
  def run(args)
19
22
  opts, = parse_flags(args, KNOWN_FLAGS)
20
23
 
@@ -22,7 +25,13 @@ module Fp
22
25
  require_flag(opts, :slug, '--slug is required (immutable identifier for the requirement)')
23
26
  require_flag(opts, :name, '--name is required (human-readable title)')
24
27
 
25
- workspace_id = resolve_workspace_id(opts[:project])
28
+ # Validate category client-side for better UX (fail fast with helpful message)
29
+ if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
30
+ output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")
31
+ exit 1
32
+ end
33
+
34
+ project_id = resolve_project_id(opts[:project])
26
35
 
27
36
  # Parse required surfaces (comma-separated)
28
37
  required_surfaces = if opts[:required]
@@ -34,15 +43,16 @@ module Fp
34
43
  params = {
35
44
  slug: opts[:slug],
36
45
  title: opts[:name],
37
- workspace_id: workspace_id,
46
+ project_id: project_id,
38
47
  required_surfaces: required_surfaces
39
48
  }
40
49
  params[:why] = opts[:why] if opts[:why]
41
50
  params[:acceptance] = opts[:acceptance] if opts[:acceptance]
51
+ params[:category] = opts[:category] if opts[:category]
42
52
 
43
53
  # Resolve parent slug to parent_id if provided
44
54
  if opts[:parent]
45
- parent_id = resolve_parent_id(workspace_id, opts[:parent])
55
+ parent_id = resolve_parent_id(project_id, opts[:parent])
46
56
  params[:parent_id] = parent_id
47
57
  end
48
58
 
@@ -62,6 +72,7 @@ module Fp
62
72
  puts
63
73
  puts " Title: #{requirement['title']}"
64
74
  puts " Status: #{requirement['status']}"
75
+ puts " Category: #{requirement['category'] || '(none)'}"
65
76
  puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
66
77
  puts " Parent: #{opts[:parent] || '(none)'}" if opts[:parent]
67
78
  puts
@@ -74,8 +85,8 @@ module Fp
74
85
  private
75
86
 
76
87
  # Resolve a parent requirement slug to its ID
77
- def resolve_parent_id(workspace_id, parent_slug)
78
- result = client.list_requirements(workspace_id: workspace_id)
88
+ def resolve_parent_id(project_id, parent_slug)
89
+ result = client.list_requirements(project_id: project_id)
79
90
  unless result[:ok]
80
91
  output.error("Failed to resolve parent slug '#{parent_slug}': #{result[:error]}")
81
92
  exit 1
@@ -85,7 +96,7 @@ module Fp
85
96
  parent = requirements.find { |r| r['slug'] == parent_slug }
86
97
 
87
98
  unless parent
88
- output.error("Parent requirement '#{parent_slug}' not found in this workspace")
99
+ output.error("Parent requirement '#{parent_slug}' not found in this project")
89
100
  exit 1
90
101
  end
91
102