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.
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/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.2'
4
+ VERSION = '0.0.3'
5
5
  end
data/lib/fp.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative 'fp/version'
4
4
  require_relative 'fp/config'
5
5
  require_relative 'fp/client'
6
+ require_relative 'fp/junit'
6
7
  require_relative 'fp/output'
7
8
  require_relative 'fp/commands'
8
9
  require_relative 'fp/cli'
@@ -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`
@@ -0,0 +1,219 @@
1
+ # fp CLI Reference
2
+
3
+ Complete flag reference for the `fp` command-line tool.
4
+
5
+ ## Global flags
6
+
7
+ These work with any command:
8
+
9
+ | Flag | Description |
10
+ |------|-------------|
11
+ | `--json` | Output JSON instead of human-readable text |
12
+ | `--profile NAME` | Use a named profile from `~/.config/fp/config.yml` |
13
+ | `--api-url URL` | Override the API URL for this invocation |
14
+ | `--help` | Show help for any command |
15
+
16
+ ## Commands
17
+
18
+ ### fp setup
19
+
20
+ Interactive first-run configuration. Validates API key and saves a profile.
21
+
22
+ ```bash
23
+ fp setup # Interactive
24
+ fp setup --api-key fp_... --non-interactive # Non-interactive
25
+ fp setup --api-key fp_... --profile ci # Named profile
26
+ ```
27
+
28
+ ### fp projects
29
+
30
+ List accessible projects.
31
+
32
+ ```bash
33
+ fp projects
34
+ fp projects --json
35
+ ```
36
+
37
+ ### fp surfaces
38
+
39
+ List surfaces for a project.
40
+
41
+ ```bash
42
+ fp surfaces --project stowzilla
43
+ fp surfaces --project stowzilla --json
44
+ ```
45
+
46
+ ### fp list
47
+
48
+ List requirements.
49
+
50
+ ```bash
51
+ fp list --project stowzilla # All active requirements
52
+ fp list --project stowzilla --gaps # Requirements without evidence
53
+ fp list --project stowzilla --status draft
54
+ fp list --project stowzilla --json
55
+ ```
56
+
57
+ | Flag | Description |
58
+ |------|-------------|
59
+ | `--project` | Project slug (required) |
60
+ | `--gaps` | Show only requirements without evidence |
61
+ | `--status` | Filter by status: active, draft, archived |
62
+ | `--category` | Filter by category |
63
+
64
+ ### fp show
65
+
66
+ Show requirement details.
67
+
68
+ ```bash
69
+ fp show print_container_qr --project stowzilla
70
+ fp show print_container_qr --project stowzilla --json
71
+ ```
72
+
73
+ ### fp report
74
+
75
+ Report evidence linking a test to a requirement.
76
+
77
+ **Single-slug mode:**
78
+
79
+ ```bash
80
+ fp report <slug> \
81
+ --project stowzilla \
82
+ --surface api \
83
+ --file spec/qr_spec.rb \
84
+ --repo stowzilla/marketplace \
85
+ [--pr URL] [--sha COMMIT] [--work-item URL] [--ci-url URL] [--title "..."] [--state present|stub]
86
+ ```
87
+
88
+ | Flag | Required | Description |
89
+ |------|----------|-------------|
90
+ | `<slug>` | Yes | Requirement slug (positional) |
91
+ | `--project` | Yes | Project slug |
92
+ | `--surface` | Yes | Surface this evidence covers |
93
+ | `--file` | Yes | Path to test file (relative to repo root) |
94
+ | `--repo` | Yes | GitHub repo as `org/repo` |
95
+ | `--pr` | No | Pull request URL |
96
+ | `--sha` | No | Commit SHA |
97
+ | `--work-item` | No | Fizzy card or issue URL |
98
+ | `--ci-url` | No | CI run URL |
99
+ | `--title` | No | Human-readable test name for display |
100
+ | `--state` | No | `present` (default) or `stub`. Never `passing`/`failing` — that's CI's job. |
101
+
102
+ **JUnit batch mode:**
103
+
104
+ ```bash
105
+ fp report --junit junit.xml \
106
+ --project stowzilla \
107
+ --surface api \
108
+ --repo stowzilla/marketplace \
109
+ [--base-dir DIR] [--pr URL] [--sha COMMIT] [--work-item URL] [--ci-url URL]
110
+ ```
111
+
112
+ | Flag | Required | Description |
113
+ |------|----------|-------------|
114
+ | `--junit` | Yes | Path to JUnit XML report |
115
+ | `--project` | Yes | Project slug |
116
+ | `--surface` | Conditional | Default surface for markers without `@suffix`. Required if any matched marker pins no surface. |
117
+ | `--repo` | Yes | GitHub repo as `org/repo` |
118
+ | `--base-dir` | No | Where to resolve relative file paths (defaults to cwd) |
119
+
120
+ In JUnit mode:
121
+ - Slugs come from `fp:<slug>` markers in the test files referenced by the report
122
+ - A marker binds to the test directly below it
123
+ - Skipped/pending tests become `stub`; all others `present`
124
+ - Unknown slugs and unmarked tests are skipped with a warning
125
+
126
+ ### fp propose
127
+
128
+ Propose a new requirement as draft.
129
+
130
+ ```bash
131
+ fp propose --project stowzilla \
132
+ --slug print_container_qr \
133
+ --name "Print QR on container label" \
134
+ --why "Enables scanning containers" \
135
+ --required api,customer_android \
136
+ --acceptance "Label shows scannable QR code"
137
+ ```
138
+
139
+ | Flag | Required | Description |
140
+ |------|----------|-------------|
141
+ | `--project` | Yes | Project slug |
142
+ | `--slug` | Yes | Requirement slug (immutable, choose carefully) |
143
+ | `--name` | Yes | Human-readable name |
144
+ | `--why` | No | Why this requirement matters |
145
+ | `--required` | No | Comma-separated surfaces that must implement this |
146
+ | `--acceptance` | No | How to verify completion |
147
+
148
+ **Agents always create requirements as draft.** A human must activate in the web app.
149
+
150
+ ### fp matrix
151
+
152
+ Display the parity matrix.
153
+
154
+ ```bash
155
+ fp matrix --project stowzilla # ASCII table
156
+ fp matrix --project stowzilla --csv # CSV export
157
+ fp matrix --project stowzilla --json # JSON
158
+ ```
159
+
160
+ ### fp profile
161
+
162
+ Manage named profiles.
163
+
164
+ ```bash
165
+ fp profile list
166
+ fp profile add staging --api-key fp_...
167
+ fp profile remove staging
168
+ fp profile show staging
169
+ ```
170
+
171
+ ### fp config
172
+
173
+ Manage global settings.
174
+
175
+ ```bash
176
+ fp config list
177
+ fp config get api_url
178
+ fp config set api_url https://api.dev.featureparity.dev
179
+ fp config unset api_url
180
+ ```
181
+
182
+ ### fp repos
183
+
184
+ Map surfaces to local repo paths (stored locally, not in the web app).
185
+
186
+ ```bash
187
+ fp repos list --project stowzilla
188
+ fp repos set customer_android ~/code/customer-android --project stowzilla --repo org/repo
189
+ fp repos get customer_android --project stowzilla
190
+ fp repos unset customer_android --project stowzilla
191
+ ```
192
+
193
+ ## Exit codes
194
+
195
+ | Code | Meaning |
196
+ |------|---------|
197
+ | 0 | Success |
198
+ | 1 | General error (invalid flags, missing required args, API error) |
199
+
200
+ ## Configuration files
201
+
202
+ - `~/.config/fp/config.yml` — Profiles and global settings
203
+ - `FP_API_KEY` environment variable overrides all profiles
204
+ - `FP_API_URL` environment variable overrides API URL
205
+ - `FP_PROFILE` environment variable selects a profile
206
+
207
+ ## API URL priority
208
+
209
+ 1. `--api-url` flag
210
+ 2. `FP_API_URL` environment variable
211
+ 3. Profile `api_url` setting
212
+ 4. Global `api_url` setting
213
+ 5. Default: `https://api.featureparity.dev`
214
+
215
+ ## Profile resolution priority
216
+
217
+ 1. `--profile` flag
218
+ 2. `FP_PROFILE` environment variable
219
+ 3. `default` profile (if it exists)