featureparity 0.0.1 → 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 +4 -4
- data/lib/fp/cli.rb +19 -4
- data/lib/fp/client.rb +24 -17
- data/lib/fp/commands/base.rb +7 -7
- data/lib/fp/commands/config_cmd.rb +130 -0
- data/lib/fp/commands/help.rb +40 -1
- data/lib/fp/commands/list.rb +58 -17
- data/lib/fp/commands/matrix.rb +7 -10
- data/lib/fp/commands/projects.rb +11 -5
- data/lib/fp/commands/propose.rb +45 -4
- data/lib/fp/commands/report.rb +161 -12
- data/lib/fp/commands/repos.rb +169 -0
- data/lib/fp/commands/setup.rb +11 -4
- data/lib/fp/commands/show.rb +4 -2
- data/lib/fp/commands/surfaces.rb +5 -5
- data/lib/fp/commands.rb +2 -0
- data/lib/fp/config.rb +151 -22
- data/lib/fp/junit.rb +212 -0
- data/lib/fp/output.rb +1 -1
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +140 -0
- data/skills/fp/references/cli.md +219 -0
- data/skills/fp/references/markers.md +149 -0
- metadata +8 -2
data/lib/fp/config.rb
CHANGED
|
@@ -5,16 +5,33 @@ require 'fileutils'
|
|
|
5
5
|
|
|
6
6
|
module Fp
|
|
7
7
|
# Handles configuration: profiles, API keys, API URL.
|
|
8
|
-
#
|
|
8
|
+
#
|
|
9
|
+
# API URL resolution priority (highest wins):
|
|
10
|
+
# 1. --api-url CLI flag (passed via constructor)
|
|
11
|
+
# 2. FP_API_URL env var
|
|
12
|
+
# 3. Profile-specific api_url (in profile config)
|
|
13
|
+
# 4. Global api_url setting (top-level in config.yml)
|
|
14
|
+
# 5. DEFAULT_API_URL
|
|
15
|
+
#
|
|
16
|
+
# API key resolution priority:
|
|
17
|
+
# 1. FP_API_KEY env var
|
|
18
|
+
# 2. Profile api_key (--profile flag > FP_PROFILE env var > "default" profile)
|
|
19
|
+
#
|
|
20
|
+
# Profile name resolution priority:
|
|
21
|
+
# 1. --profile CLI flag
|
|
22
|
+
# 2. FP_PROFILE env var
|
|
23
|
+
# 3. A profile literally named "default" (if one exists in config.yml)
|
|
9
24
|
class Config
|
|
10
25
|
DEFAULT_API_URL = 'https://api.featureparity.dev'
|
|
26
|
+
DEFAULT_PROFILE_NAME = 'default'
|
|
11
27
|
CONFIG_DIR = File.expand_path('~/.config/fp')
|
|
12
28
|
CONFIG_FILE = File.join(CONFIG_DIR, 'config.yml')
|
|
13
29
|
|
|
14
30
|
attr_reader :api_key, :api_url, :profile_name
|
|
15
31
|
|
|
16
|
-
def initialize(profile: nil)
|
|
32
|
+
def initialize(profile: nil, api_url: nil)
|
|
17
33
|
@profile_name = resolve_profile_name(profile)
|
|
34
|
+
@explicit_api_url = api_url
|
|
18
35
|
@api_key = resolve_api_key
|
|
19
36
|
@api_url = resolve_api_url
|
|
20
37
|
end
|
|
@@ -33,34 +50,51 @@ module Fp
|
|
|
33
50
|
end
|
|
34
51
|
|
|
35
52
|
class << self
|
|
36
|
-
def
|
|
53
|
+
def load_config
|
|
37
54
|
return {} unless File.exist?(CONFIG_FILE)
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
config['profiles'] || {}
|
|
56
|
+
YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Symbol]) || {}
|
|
41
57
|
rescue StandardError => e
|
|
42
58
|
warn "Warning: Could not load config file: #{e.message}"
|
|
43
59
|
{}
|
|
44
60
|
end
|
|
45
61
|
|
|
62
|
+
def load_profiles
|
|
63
|
+
config = load_config
|
|
64
|
+
config['profiles'] || {}
|
|
65
|
+
end
|
|
66
|
+
|
|
46
67
|
def save_profile(name, api_key:, api_url: nil)
|
|
47
68
|
FileUtils.mkdir_p(CONFIG_DIR)
|
|
69
|
+
ensure_config_file!
|
|
48
70
|
|
|
49
|
-
|
|
50
|
-
unless File.exist?(CONFIG_FILE)
|
|
51
|
-
File.write(CONFIG_FILE, "---\nprofiles: {}\n")
|
|
52
|
-
File.chmod(0o600, CONFIG_FILE)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
config = YAML.safe_load_file(CONFIG_FILE, permitted_classes: [Symbol]) || {}
|
|
71
|
+
config = load_config
|
|
56
72
|
config['profiles'] ||= {}
|
|
57
|
-
config['profiles'][name] = {
|
|
58
|
-
'api_key' => api_key
|
|
59
|
-
}
|
|
73
|
+
config['profiles'][name] = { 'api_key' => api_key }
|
|
60
74
|
config['profiles'][name]['api_url'] = api_url if api_url
|
|
61
75
|
|
|
62
|
-
|
|
63
|
-
|
|
76
|
+
write_config!(config)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get a global setting (top-level key in config.yml)
|
|
80
|
+
def get_setting(key)
|
|
81
|
+
config = load_config
|
|
82
|
+
config[key.to_s]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Set a global setting (top-level key in config.yml)
|
|
86
|
+
def set_setting(key, value)
|
|
87
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
88
|
+
ensure_config_file!
|
|
89
|
+
|
|
90
|
+
config = load_config
|
|
91
|
+
if value.nil?
|
|
92
|
+
config.delete(key.to_s)
|
|
93
|
+
else
|
|
94
|
+
config[key.to_s] = value
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
write_config!(config)
|
|
64
98
|
end
|
|
65
99
|
|
|
66
100
|
def list_profiles
|
|
@@ -70,13 +104,101 @@ module Fp
|
|
|
70
104
|
def profile_exists?(name)
|
|
71
105
|
load_profiles.key?(name)
|
|
72
106
|
end
|
|
107
|
+
|
|
108
|
+
# --- Repo → surface mappings (per-machine, local) ---
|
|
109
|
+
#
|
|
110
|
+
# Stored under a top-level `repos` key in config.yml, scoped by project slug:
|
|
111
|
+
#
|
|
112
|
+
# repos:
|
|
113
|
+
# stowzilla:
|
|
114
|
+
# customer_android:
|
|
115
|
+
# path: /home/dev/code/customer-android
|
|
116
|
+
# repo: stowzilla/customer-android
|
|
117
|
+
#
|
|
118
|
+
# This is intentionally local (per fp installation) — it maps FeatureParity
|
|
119
|
+
# surfaces to wherever the code lives on THIS machine, so an agent working a
|
|
120
|
+
# requirement for a given surface knows which directory to open. Analogous to
|
|
121
|
+
# how `brainiac projects` maps repos to project names.
|
|
122
|
+
|
|
123
|
+
# Return the full repos mapping ({ project => { surface => {path, repo} } }).
|
|
124
|
+
def repos_map
|
|
125
|
+
config = load_config
|
|
126
|
+
config['repos'] || {}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Return the surface => {path, repo} mapping for a single project.
|
|
130
|
+
def repos_for_project(project)
|
|
131
|
+
repos_map[project.to_s] || {}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Look up the mapping for a single surface within a project.
|
|
135
|
+
# Returns a hash like { 'path' => ..., 'repo' => ... } or nil if unmapped.
|
|
136
|
+
def repo_for_surface(project, surface)
|
|
137
|
+
repos_for_project(project)[surface.to_s]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Map a surface (within a project) to a local path and optional org/repo.
|
|
141
|
+
def set_repo(project, surface, path:, repo: nil)
|
|
142
|
+
FileUtils.mkdir_p(CONFIG_DIR)
|
|
143
|
+
ensure_config_file!
|
|
144
|
+
|
|
145
|
+
config = load_config
|
|
146
|
+
config['repos'] ||= {}
|
|
147
|
+
config['repos'][project.to_s] ||= {}
|
|
148
|
+
|
|
149
|
+
entry = { 'path' => path }
|
|
150
|
+
entry['repo'] = repo if repo && !repo.to_s.empty?
|
|
151
|
+
config['repos'][project.to_s][surface.to_s] = entry
|
|
152
|
+
|
|
153
|
+
write_config!(config)
|
|
154
|
+
entry
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Remove a surface mapping within a project. Prunes empty project hashes.
|
|
158
|
+
# Returns true if something was removed, false otherwise.
|
|
159
|
+
def unset_repo(project, surface)
|
|
160
|
+
config = load_config
|
|
161
|
+
return false unless config['repos'].is_a?(Hash)
|
|
162
|
+
|
|
163
|
+
project_map = config['repos'][project.to_s]
|
|
164
|
+
return false unless project_map.is_a?(Hash) && project_map.key?(surface.to_s)
|
|
165
|
+
|
|
166
|
+
project_map.delete(surface.to_s)
|
|
167
|
+
config['repos'].delete(project.to_s) if project_map.empty?
|
|
168
|
+
config.delete('repos') if config['repos'].empty?
|
|
169
|
+
|
|
170
|
+
write_config!(config)
|
|
171
|
+
true
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def ensure_config_file!
|
|
177
|
+
return if File.exist?(CONFIG_FILE)
|
|
178
|
+
|
|
179
|
+
File.write(CONFIG_FILE, "---\nprofiles: {}\n")
|
|
180
|
+
File.chmod(0o600, CONFIG_FILE)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def write_config!(config)
|
|
184
|
+
File.write(CONFIG_FILE, YAML.dump(config))
|
|
185
|
+
File.chmod(0o600, CONFIG_FILE)
|
|
186
|
+
end
|
|
73
187
|
end
|
|
74
188
|
|
|
75
189
|
private
|
|
76
190
|
|
|
77
191
|
def resolve_profile_name(explicit_profile)
|
|
78
|
-
# Explicit --profile flag
|
|
79
|
-
explicit_profile || ENV['FP_PROFILE']
|
|
192
|
+
# 1. Explicit --profile flag 2. FP_PROFILE env var
|
|
193
|
+
name = explicit_profile || ENV['FP_PROFILE']
|
|
194
|
+
return name if name && !name.empty?
|
|
195
|
+
|
|
196
|
+
# 3. Fall back to a profile literally named "default", if one exists.
|
|
197
|
+
# This mirrors AWS/git/gcloud, which all consult a default profile when
|
|
198
|
+
# nothing else is specified.
|
|
199
|
+
return DEFAULT_PROFILE_NAME if self.class.profile_exists?(DEFAULT_PROFILE_NAME)
|
|
200
|
+
|
|
201
|
+
nil
|
|
80
202
|
end
|
|
81
203
|
|
|
82
204
|
def resolve_api_key
|
|
@@ -94,17 +216,24 @@ module Fp
|
|
|
94
216
|
end
|
|
95
217
|
|
|
96
218
|
def resolve_api_url
|
|
97
|
-
#
|
|
219
|
+
# 1. Explicit --api-url flag
|
|
220
|
+
return @explicit_api_url if @explicit_api_url && !@explicit_api_url.empty?
|
|
221
|
+
|
|
222
|
+
# 2. FP_API_URL env var
|
|
98
223
|
return ENV['FP_API_URL'] if ENV['FP_API_URL'] && !ENV['FP_API_URL'].empty?
|
|
99
224
|
|
|
100
|
-
#
|
|
225
|
+
# 3. Profile-specific URL
|
|
101
226
|
if @profile_name
|
|
102
227
|
profiles = self.class.load_profiles
|
|
103
228
|
profile = profiles[@profile_name]
|
|
104
229
|
return profile['api_url'] if profile && profile['api_url']
|
|
105
230
|
end
|
|
106
231
|
|
|
107
|
-
#
|
|
232
|
+
# 4. Global api_url setting
|
|
233
|
+
global_url = self.class.get_setting('api_url')
|
|
234
|
+
return global_url if global_url && !global_url.empty?
|
|
235
|
+
|
|
236
|
+
# 5. Default
|
|
108
237
|
DEFAULT_API_URL
|
|
109
238
|
end
|
|
110
239
|
end
|
data/lib/fp/junit.rb
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rexml/document'
|
|
4
|
+
|
|
5
|
+
module Fp
|
|
6
|
+
# Parses JUnit XML reports and binds testcases to fp:<slug> markers found in
|
|
7
|
+
# the referenced source files.
|
|
8
|
+
#
|
|
9
|
+
# This is what powers "upload evidence without CI": an agent runs the test
|
|
10
|
+
# suite locally, produces a JUnit XML report, and `fp report --junit` walks
|
|
11
|
+
# the results, discovers the fp:<slug> markers in the test files, and uploads
|
|
12
|
+
# evidence for each requirement the suite covered.
|
|
13
|
+
module JUnit
|
|
14
|
+
# A single parsed testcase.
|
|
15
|
+
TestCase = Struct.new(:name, :classname, :file, :line, :status, keyword_init: true) do
|
|
16
|
+
# Agents only ever report present/stub — never passing/failing (that's CI).
|
|
17
|
+
# A skipped test is treated as a stub; anything else counts as present.
|
|
18
|
+
def stub?
|
|
19
|
+
status == :skipped
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Evidence discovered by binding a testcase to a marker.
|
|
24
|
+
Binding = Struct.new(:slug, :surface, :file, :title, :state, keyword_init: true)
|
|
25
|
+
|
|
26
|
+
# Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
|
|
27
|
+
# `@surface1,surface2`) suffix to target one or more specific surfaces:
|
|
28
|
+
#
|
|
29
|
+
# fp:print_qr # uses the --surface flag as the default
|
|
30
|
+
# fp:print_qr@api # reports evidence for the `api` surface
|
|
31
|
+
# fp:print_qr@api,web # reports for both `api` and `web`
|
|
32
|
+
#
|
|
33
|
+
# Slugs and surfaces are lowercase letters, digits, underscores and hyphens
|
|
34
|
+
# (matching the propose/report convention).
|
|
35
|
+
MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?/.freeze
|
|
36
|
+
|
|
37
|
+
module_function
|
|
38
|
+
|
|
39
|
+
# Parse a JUnit XML file into an array of TestCase structs.
|
|
40
|
+
# Raises ArgumentError if the file is missing or unparseable.
|
|
41
|
+
def parse_file(path)
|
|
42
|
+
raise ArgumentError, "JUnit file not found: #{path}" unless File.file?(path)
|
|
43
|
+
|
|
44
|
+
xml = File.read(path)
|
|
45
|
+
parse_string(xml)
|
|
46
|
+
rescue REXML::ParseException => e
|
|
47
|
+
raise ArgumentError, "Could not parse JUnit XML (#{path}): #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse_string(xml)
|
|
51
|
+
doc = REXML::Document.new(xml)
|
|
52
|
+
cases = []
|
|
53
|
+
|
|
54
|
+
doc.each_element('//testcase') do |el|
|
|
55
|
+
cases << TestCase.new(
|
|
56
|
+
name: el.attributes['name'],
|
|
57
|
+
classname: el.attributes['classname'],
|
|
58
|
+
file: el.attributes['file'],
|
|
59
|
+
line: (el.attributes['line'] && el.attributes['line'].to_i),
|
|
60
|
+
status: testcase_status(el)
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
cases
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Determine present/skipped for a testcase element.
|
|
68
|
+
# Failures/errors still count as "present" for agent evidence — the test
|
|
69
|
+
# exists and was executed. Only skipped/pending tests become stubs.
|
|
70
|
+
def testcase_status(el)
|
|
71
|
+
return :skipped if el.get_elements('skipped').any?
|
|
72
|
+
|
|
73
|
+
:present
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Bind parsed testcases to fp:<slug> markers found in their source files.
|
|
77
|
+
#
|
|
78
|
+
# A marker binds to the test it annotates — the testcase whose line is the
|
|
79
|
+
# smallest line strictly greater than the marker's line (i.e. the next test
|
|
80
|
+
# below the marker in the same file). This mirrors the convention that the
|
|
81
|
+
# `fp:<slug>` comment sits immediately above its test. Tests with no marker
|
|
82
|
+
# directly above them are left unmatched rather than being bound to an
|
|
83
|
+
# unrelated marker.
|
|
84
|
+
#
|
|
85
|
+
# When testcases carry no line information, a file with exactly one marker
|
|
86
|
+
# binds that marker to every testcase in the file (common for small,
|
|
87
|
+
# single-requirement test files); files with multiple markers and no line
|
|
88
|
+
# info can't be disambiguated and are reported as unmatched.
|
|
89
|
+
#
|
|
90
|
+
# A marker may pin one or more surfaces via `@surface` / `@a,b` (e.g. a
|
|
91
|
+
# backend test that satisfies several surfaces). Each surface yields its own
|
|
92
|
+
# binding. Markers without a surface fall back to `default_surface`.
|
|
93
|
+
#
|
|
94
|
+
# base_dir: directory to resolve relative testcase file paths against.
|
|
95
|
+
# default_surface: surface applied to markers that don't pin their own.
|
|
96
|
+
#
|
|
97
|
+
# Returns a hash:
|
|
98
|
+
# {
|
|
99
|
+
# bindings: [Binding, ...], # unique (slug, surface, file) evidence
|
|
100
|
+
# unmatched: [TestCase, ...], # testcases with no discoverable marker
|
|
101
|
+
# missing_surface: [Binding-ish], # markers with no surface and no default
|
|
102
|
+
# }
|
|
103
|
+
def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil)
|
|
104
|
+
marker_cache = {}
|
|
105
|
+
bindings = {}
|
|
106
|
+
matched = {}
|
|
107
|
+
missing_surface = []
|
|
108
|
+
|
|
109
|
+
# Group testcases by their source file so we can reason about ordering.
|
|
110
|
+
by_file = testcases.group_by(&:file)
|
|
111
|
+
|
|
112
|
+
by_file.each do |file, cases|
|
|
113
|
+
markers = markers_for(cases.first, base_dir, marker_cache)
|
|
114
|
+
next if markers.empty?
|
|
115
|
+
|
|
116
|
+
cases.each do |tc|
|
|
117
|
+
marker = marker_for(tc, cases, markers)
|
|
118
|
+
next unless marker
|
|
119
|
+
|
|
120
|
+
matched[tc.object_id] = true
|
|
121
|
+
|
|
122
|
+
surfaces = marker[:surfaces]
|
|
123
|
+
surfaces = [default_surface] if surfaces.empty?
|
|
124
|
+
|
|
125
|
+
surfaces.each do |surface|
|
|
126
|
+
if surface.nil? || surface.empty?
|
|
127
|
+
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
128
|
+
else
|
|
129
|
+
record_binding(bindings, marker[:slug], surface, tc)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
unmatched = testcases.reject { |tc| matched[tc.object_id] }
|
|
136
|
+
{ bindings: bindings.values, unmatched: unmatched, missing_surface: missing_surface.uniq }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Determine the marker (slug + surfaces) that annotates a testcase, or nil.
|
|
140
|
+
def marker_for(testcase, sibling_cases, markers)
|
|
141
|
+
if testcase.line
|
|
142
|
+
marker_for_line(testcase.line, sibling_cases, markers)
|
|
143
|
+
elsif markers.length == 1 && sibling_cases.none?(&:line)
|
|
144
|
+
# No line info anywhere and a single marker: unambiguous.
|
|
145
|
+
markers.first
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# A marker annotates the testcase that is the first test below it. Given a
|
|
150
|
+
# testcase line, find the marker that sits directly above it with no other
|
|
151
|
+
# testcase in between.
|
|
152
|
+
def marker_for_line(line, sibling_cases, markers)
|
|
153
|
+
candidate = markers.select { |m| m[:line] < line }.max_by { |m| m[:line] }
|
|
154
|
+
return nil unless candidate
|
|
155
|
+
|
|
156
|
+
# Reject if another testcase falls between the marker and this test —
|
|
157
|
+
# that means the marker belongs to the intervening test, not this one.
|
|
158
|
+
intervening = sibling_cases.any? do |other|
|
|
159
|
+
other.line && other.line > candidate[:line] && other.line < line
|
|
160
|
+
end
|
|
161
|
+
return nil if intervening
|
|
162
|
+
|
|
163
|
+
candidate
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def record_binding(bindings, slug, surface, testcase)
|
|
167
|
+
rel_file = testcase.file
|
|
168
|
+
key = [slug, surface, rel_file]
|
|
169
|
+
state = testcase.stub? ? 'stub' : 'present'
|
|
170
|
+
|
|
171
|
+
existing = bindings[key]
|
|
172
|
+
# Prefer a concrete 'present' state over 'stub' when a slug has both.
|
|
173
|
+
return if existing && !(existing.state == 'stub' && state == 'present')
|
|
174
|
+
|
|
175
|
+
bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file,
|
|
176
|
+
title: testcase.name, state: state)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Load and cache the fp:<slug> markers (with line numbers) for a testcase's
|
|
180
|
+
# source file. Returns [] when the file is unknown or unreadable.
|
|
181
|
+
def markers_for(testcase, base_dir, cache)
|
|
182
|
+
file = testcase.file
|
|
183
|
+
return [] if file.nil? || file.empty?
|
|
184
|
+
|
|
185
|
+
return cache[file] if cache.key?(file)
|
|
186
|
+
|
|
187
|
+
resolved = File.expand_path(file, base_dir)
|
|
188
|
+
markers =
|
|
189
|
+
if File.file?(resolved)
|
|
190
|
+
scan_markers(File.read(resolved))
|
|
191
|
+
else
|
|
192
|
+
[]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
cache[file] = markers
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
|
|
199
|
+
# [{ slug:, surfaces: [..], line: }, ...] (1-based line numbers).
|
|
200
|
+
# `surfaces` is [] when the marker pins none (use the default surface).
|
|
201
|
+
def scan_markers(contents)
|
|
202
|
+
markers = []
|
|
203
|
+
contents.each_line.with_index(1) do |line, num|
|
|
204
|
+
line.scan(MARKER_RE) do |(slug, surface_list)|
|
|
205
|
+
surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
|
|
206
|
+
markers << { slug: slug, surfaces: surfaces, line: num }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
markers
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
data/lib/fp/output.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
-
require 'csv'
|
|
5
4
|
|
|
6
5
|
module Fp
|
|
7
6
|
# Output formatting for CLI responses
|
|
@@ -61,6 +60,7 @@ module Fp
|
|
|
61
60
|
end
|
|
62
61
|
|
|
63
62
|
def csv(headers, rows)
|
|
63
|
+
require 'csv'
|
|
64
64
|
csv_string = CSV.generate do |csv|
|
|
65
65
|
csv << headers
|
|
66
66
|
rows.each { |row| csv << row }
|
data/lib/fp/version.rb
CHANGED
data/lib/fp.rb
CHANGED
data/skills/fp/SKILL.md
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: fp
|
|
3
|
+
description: "Use the fp CLI to track feature parity: report evidence linking tests to requirements, propose new requirements as drafts, view the parity matrix, and upload test results from JUnit XML when CI isn't available."
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: Requires the fp executable on PATH (gem install featureparity)
|
|
6
|
+
metadata:
|
|
7
|
+
gem: featureparity
|
|
8
|
+
binary: fp
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# fp — FeatureParity CLI
|
|
12
|
+
|
|
13
|
+
Prefer the installed `fp` binary over inventing equivalent Ruby. Confirm it exists first:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
command -v fp && fp --version
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
If missing: `gem install featureparity` (or `bundle exec fp` inside an app that already depends on the gem).
|
|
20
|
+
|
|
21
|
+
## Non-interactive rules
|
|
22
|
+
|
|
23
|
+
- Always pass flags. Never rely on prompts or TTY menus.
|
|
24
|
+
- Use `--json` when parsing output programmatically.
|
|
25
|
+
- Use `fp help <command>` before destructive commands.
|
|
26
|
+
- Treat non-zero exit as failure; read stderr.
|
|
27
|
+
|
|
28
|
+
## Authentication
|
|
29
|
+
|
|
30
|
+
`fp` needs an API key. Check if configured:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Environment variable (preferred for agents)
|
|
34
|
+
echo $FP_API_KEY
|
|
35
|
+
|
|
36
|
+
# Or via profile
|
|
37
|
+
fp profile list
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If not set, ask the user for their API key or have them run `fp setup`.
|
|
41
|
+
|
|
42
|
+
## Core workflows
|
|
43
|
+
|
|
44
|
+
### 1. Check existing requirements
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
fp list --project stowzilla # All active requirements
|
|
48
|
+
fp list --project stowzilla --gaps # Requirements without evidence
|
|
49
|
+
fp show <slug> --project stowzilla # Requirement details
|
|
50
|
+
fp matrix --project stowzilla # ASCII parity matrix
|
|
51
|
+
fp matrix --project stowzilla --csv # Export as CSV
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2. Add the fp:<slug> marker to tests
|
|
55
|
+
|
|
56
|
+
Place a comment immediately before the test to bind it to a requirement:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# fp:print_container_qr
|
|
60
|
+
it 'prints a QR code onto the container label' do
|
|
61
|
+
expect(label.qr_code).to be_present
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Rules:**
|
|
66
|
+
- Use `fp:<slug>` where `<slug>` is the requirement's slug
|
|
67
|
+
- Keep test names human-readable — the marker handles binding
|
|
68
|
+
- Pin surfaces with `@suffix`: `fp:print_qr@api,web` reports for both surfaces
|
|
69
|
+
|
|
70
|
+
### 3. Report evidence (single test)
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
fp report <slug> \
|
|
74
|
+
--project stowzilla \
|
|
75
|
+
--surface api \
|
|
76
|
+
--file spec/qr_spec.rb \
|
|
77
|
+
--repo stowzilla/marketplace \
|
|
78
|
+
--work-item https://app.fizzy.do/123/cards/456
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Required:** `<slug>`, `--project`, `--surface`, `--file`, `--repo`
|
|
82
|
+
**Optional:** `--pr`, `--sha`, `--work-item`, `--ci-url`, `--title`
|
|
83
|
+
|
|
84
|
+
**Agents report `present` (default) or `stub` only. Never `passing` or `failing`.**
|
|
85
|
+
|
|
86
|
+
### 4. Report evidence from JUnit XML (batch upload without CI)
|
|
87
|
+
|
|
88
|
+
When CI isn't available, run the suite locally and upload the report:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# 1. Generate JUnit XML
|
|
92
|
+
rspec --format RspecJunitFormatter --out junit.xml
|
|
93
|
+
|
|
94
|
+
# 2. Upload — one call covers every marked test
|
|
95
|
+
fp report --junit junit.xml \
|
|
96
|
+
--project stowzilla \
|
|
97
|
+
--surface api \
|
|
98
|
+
--repo stowzilla/marketplace \
|
|
99
|
+
--work-item https://app.fizzy.do/123/cards/456
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
- No `<slug>` positional needed — slugs come from the `fp:<slug>` markers
|
|
103
|
+
- Skipped tests become `stub`; all others `present`
|
|
104
|
+
- Unknown slugs and unmarked tests are skipped with a warning
|
|
105
|
+
- `--surface` is the default; markers can override with `@suffix`
|
|
106
|
+
- `--base-dir DIR` sets where relative paths resolve from
|
|
107
|
+
|
|
108
|
+
### 5. Propose new requirements
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
fp propose --project stowzilla \
|
|
112
|
+
--slug print_container_qr \
|
|
113
|
+
--name "Print QR on container label" \
|
|
114
|
+
--why "Enables scanning containers in the warehouse" \
|
|
115
|
+
--required api,customer_android \
|
|
116
|
+
--acceptance "Label shows scannable QR code"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
**Agents always create requirements as draft.** A human must activate in the web app.
|
|
120
|
+
|
|
121
|
+
## Quick reference
|
|
122
|
+
|
|
123
|
+
| Task | Command |
|
|
124
|
+
|------|---------|
|
|
125
|
+
| List requirements | `fp list --project X` |
|
|
126
|
+
| Show gaps | `fp list --project X --gaps` |
|
|
127
|
+
| Show details | `fp show <slug> --project X` |
|
|
128
|
+
| Report evidence | `fp report <slug> --project X --surface Y --file Z --repo A/B` |
|
|
129
|
+
| Report from JUnit | `fp report --junit file.xml --project X --surface Y --repo A/B` |
|
|
130
|
+
| Propose requirement | `fp propose --project X --slug Y --name "..."` |
|
|
131
|
+
| View matrix | `fp matrix --project X` |
|
|
132
|
+
| List surfaces | `fp surfaces --project X` |
|
|
133
|
+
| List projects | `fp projects` |
|
|
134
|
+
|
|
135
|
+
All commands support `--json` for machine-readable output.
|
|
136
|
+
|
|
137
|
+
## When to read more
|
|
138
|
+
|
|
139
|
+
- Full flag reference and exit codes → `references/cli.md`
|
|
140
|
+
- Marker syntax and examples → `references/markers.md`
|