featureparity 0.0.4 → 0.0.5

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: 0b5b8ef2f8e259e3d8d7ecd0c547ad8bef6f6202e0b3564bd8d789de905661ed
4
- data.tar.gz: 79d02f1bfdd3c95b1d94a40b2cda406d656c4970a0c6b3f60b792415b4417c62
3
+ metadata.gz: 1208887c7ed99691e476438cdcb50d36f09e3ee3b22931c7d8d9ea246438fb89
4
+ data.tar.gz: b2fb9b9d7bad99bde7e2f8b0789105072cd19647ed46f0ea4d4d6e43a28fd319
5
5
  SHA512:
6
- metadata.gz: e737b8e544deaa42e6ada2f6af065c5afdeaa46afa51d5365c5f8a92fa77572e1f5c1a13715a11eb0655416a08e487f4b8e9bb7f7ffa9efc3a6042abcc1c45ad
7
- data.tar.gz: 247e22500f20fcd047a617232020f29a87987ac2376a78176a69969a467b8d8164b4dd6a427c73c558965b7aa9543ffb2e0b659416974ec5272f76d2618c415e
6
+ metadata.gz: 5d70c2ae5afb585a1f54da0c2efb244965a0f419001c9ed8f46d1895a5f37ee12ece8a1195627859eeeaff2fd3910cf4dfdb593fc41c69440bc97020ff31a17a
7
+ data.tar.gz: 23d9038f6850138f295501a56ea00f872ff828eab5a2822482c306a56ba39cdec2a1016761af8f4f87932a3d9f264faf0c9dad3302d2a96ea5adceb86b3e29c6
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,6 +72,15 @@ 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)
@@ -14,6 +14,7 @@ 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)
@@ -68,6 +69,15 @@ module Fp
68
69
  # List projects
69
70
  fp projects
70
71
 
72
+ # List the surfaces a project tracks (requirements may only name these)
73
+ fp surfaces --project stowzilla
74
+
75
+ # Create a surface (agents can do this — no need to wait on the web app)
76
+ fp surfaces add customer_android --project stowzilla \\
77
+ --name "Customer Android App" \\
78
+ --kind android \\
79
+ --repo stowzilla/customer-android
80
+
71
81
  # List requirements for a project
72
82
  fp list --project stowzilla
73
83
 
@@ -89,6 +99,14 @@ module Fp
89
99
  --acceptance "QR code is printed and scannable" \\
90
100
  --category functional
91
101
 
102
+ # Nest a requirement under a parent (--parent takes the parent's SLUG).
103
+ # A child's --required surfaces must be a subset of its parent's.
104
+ fp propose --project stowzilla \\
105
+ --slug print_container_qr_ios \\
106
+ --name "Print QR on iOS" \\
107
+ --parent print_container_qr \\
108
+ --required customer_ios
109
+
92
110
  # Report evidence for a requirement
93
111
  fp report print_container_qr --project stowzilla \\
94
112
  --surface customer_android \\
@@ -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]
@@ -25,21 +25,49 @@ module Fp
25
25
 
26
26
  requirement = result[:data]['requirement']
27
27
 
28
- output.success(result[:data]) do
29
- print_requirement(requirement)
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 print_requirement(req)
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 (will be populated when Evidence model exists)
67
- evidence = req['evidence'] || []
68
- if evidence.any?
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']}"
@@ -2,9 +2,38 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp surfaces --project <slug> - list available surfaces for a project
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 'Configure surfaces in the web app.'
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/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: el.attributes['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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.4'
4
+ VERSION = '0.0.5'
5
5
  end
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
4
+ version: 0.0.5
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-12 00:00:00.000000000 Z
11
+ date: 2026-09-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http