featureparity 0.0.4 → 0.0.6

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: 59cbbdb1a7c4c7af5ab1d7016d22c7fdf670855c9e75a5c9d7350752c885b625
4
+ data.tar.gz: 25d42454129c6d27c222e5d512578991b13a2abda65af67f8f39139550d24e96
5
5
  SHA512:
6
- metadata.gz: e737b8e544deaa42e6ada2f6af065c5afdeaa46afa51d5365c5f8a92fa77572e1f5c1a13715a11eb0655416a08e487f4b8e9bb7f7ffa9efc3a6042abcc1c45ad
7
- data.tar.gz: 247e22500f20fcd047a617232020f29a87987ac2376a78176a69969a467b8d8164b4dd6a427c73c558965b7aa9543ffb2e0b659416974ec5272f76d2618c415e
6
+ metadata.gz: 639bc26fbac4a013d0cef2051c438f1019ce915b5ce90537fe6cea0339f443f27234ae85062ba22f93cd989e7c6982390fb3be3068cd63bf658e081795243fca
7
+ data.tar.gz: 0db47c4854e39bd6410c44839c26338d79833d129e6f68d3de26e83afff12a45b7199348fee0f1528a35697caace3e8d8084ee31438ec58474d175dcaf9a040c
data/lib/fp/cli.rb CHANGED
@@ -10,6 +10,7 @@ module Fp
10
10
  'show' => Commands::Show,
11
11
  'propose' => Commands::Propose,
12
12
  'report' => Commands::Report,
13
+ 'ci-report' => Commands::CiReport,
13
14
  'matrix' => Commands::Matrix,
14
15
  'profile' => Commands::Profile,
15
16
  'config' => Commands::ConfigCmd,
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,12 +72,36 @@ 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)
73
87
  post("/api/projects/#{project_id}/evidence", params)
74
88
  end
75
89
 
90
+ # POST /projects/:project_id/ci_results — the CI-only path.
91
+ #
92
+ # Unlike report_evidence (agents: present/stub), this hands the raw JUnit XML
93
+ # to the backend, which parses it, maps testcases to fp:<slug> markers, and
94
+ # writes passing/failing evidence per surface. It also marks previously
95
+ # passing slugs that have vanished from the results as failing. This is the
96
+ # half of the loop only CI is allowed to close (#1222).
97
+ def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:)
98
+ post("/api/projects/#{project_id}/ci_results", {
99
+ ci_url: ci_url,
100
+ surface: surface,
101
+ junit_xml: junit_xml
102
+ })
103
+ end
104
+
76
105
  # GET /matrix (when API is ready)
77
106
  def get_matrix(project_id, format: :json)
78
107
  path = format == :csv ? '/api/matrix.csv' : '/api/matrix'
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # CI-only evidence reporting from a JUnit XML report.
6
+ #
7
+ # fp ci-report --junit <path> --project <slug> --surface X --ci-url URL
8
+ #
9
+ # This is the CI counterpart to `fp report --junit`. Where `fp report`
10
+ # writes agent evidence (present/stub) and never claims a test passed, this
11
+ # command hands the raw JUnit XML to the backend's CI ingest endpoint, which:
12
+ #
13
+ # - maps each testcase to its fp:<slug> marker (server-side, same
14
+ # file-scanning convention as the CLI),
15
+ # - writes passing / failing evidence per requirement for the surface,
16
+ # - and marks any slug that was passing but has now vanished from the
17
+ # report as failing.
18
+ #
19
+ # That last part is why the whole report goes up as one document rather than
20
+ # per-slug: the server can only detect a disappeared test by diffing the full
21
+ # result set against what it had. Reporting passing/failing is a privilege of
22
+ # CI (#1222) — agents can't reach this path, they only ever report evidence
23
+ # via `fp report`.
24
+ #
25
+ # Multiple --junit flags may be passed to ingest several reports for the same
26
+ # surface in one invocation (e.g. a suite split across shards). Each is
27
+ # uploaded in turn.
28
+ class CiReport < Base
29
+ KNOWN_FLAGS = {
30
+ project: :string,
31
+ surface: :string,
32
+ ci_url: :string,
33
+ junit: :string
34
+ }.freeze
35
+
36
+ def run(args)
37
+ # Collect every --junit occurrence (parse_flags keeps only the last of a
38
+ # repeated flag, so pull them out first).
39
+ junit_paths, rest = extract_repeated(args, '--junit')
40
+ opts, _positional = parse_flags(rest, KNOWN_FLAGS)
41
+
42
+ require_flag(opts, :project)
43
+ require_flag(opts, :surface)
44
+ require_flag(opts, :ci_url)
45
+
46
+ if junit_paths.empty?
47
+ output.error('--junit <path> is required (may be given more than once).')
48
+ exit 1
49
+ end
50
+
51
+ missing = junit_paths.reject { |p| File.file?(p) }
52
+ unless missing.empty?
53
+ output.error("JUnit file(s) not found: #{missing.join(', ')}")
54
+ exit 1
55
+ end
56
+
57
+ project_id = resolve_project_id(opts[:project])
58
+
59
+ totals = { created: 0, updated: 0, missing_failing: 0, reports: [] }
60
+
61
+ junit_paths.each do |path|
62
+ xml = File.read(path)
63
+ result = client.ingest_ci_results(
64
+ project_id,
65
+ ci_url: opts[:ci_url],
66
+ surface: opts[:surface],
67
+ junit_xml: xml
68
+ )
69
+
70
+ unless result[:ok]
71
+ output.error("Failed to ingest #{path}: #{result[:error]}", status: result[:status])
72
+ exit 1
73
+ end
74
+
75
+ data = result[:data] || {}
76
+ totals[:created] += data['evidence_created'].to_i
77
+ totals[:updated] += data['evidence_updated'].to_i
78
+ totals[:missing_failing] += data['missing_marked_failing'].to_i
79
+ totals[:reports] << { path: path, results: data['results'] || [] }
80
+ end
81
+
82
+ emit_summary(opts, totals)
83
+ end
84
+
85
+ private
86
+
87
+ # Pull every occurrence of a repeatable flag (and its value) out of args,
88
+ # returning [collected_values, remaining_args]. Leaves all other flags for
89
+ # the normal parser.
90
+ def extract_repeated(args, flag)
91
+ values = []
92
+ rest = []
93
+ i = 0
94
+ while i < args.length
95
+ arg = args[i]
96
+ if arg == flag && i + 1 < args.length
97
+ values << args[i + 1]
98
+ i += 2
99
+ elsif arg.start_with?("#{flag}=")
100
+ values << arg.split('=', 2)[1]
101
+ i += 1
102
+ else
103
+ rest << arg
104
+ i += 1
105
+ end
106
+ end
107
+ [values, rest]
108
+ end
109
+
110
+ def emit_summary(opts, totals)
111
+ data = {
112
+ project: opts[:project],
113
+ surface: opts[:surface],
114
+ ci_url: opts[:ci_url],
115
+ evidence_created: totals[:created],
116
+ evidence_updated: totals[:updated],
117
+ missing_marked_failing: totals[:missing_failing],
118
+ reports: totals[:reports]
119
+ }
120
+
121
+ output.success(data) do
122
+ puts "Ingested CI results for surface '#{opts[:surface]}':"
123
+ totals[:reports].each do |report|
124
+ report[:results].each do |r|
125
+ glyph = r['status'] == 'passing' ? '✓' : (r['status'] == 'failing' ? '✗' : '•')
126
+ puts " #{glyph} #{r['slug']} (#{r['status']}) #{r['source_file']}"
127
+ end
128
+ end
129
+ puts
130
+ puts " created: #{totals[:created]} updated: #{totals[:updated]} marked failing (missing): #{totals[:missing_failing]}"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -14,11 +14,13 @@ 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)
20
21
  report <slug> --project <slug>... Report evidence for a requirement
21
22
  report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
23
+ ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
22
24
  matrix --project <slug> Show the parity matrix
23
25
  profile add|list Manage named profiles
24
26
  config set|get|unset|list Manage global settings
@@ -68,6 +70,15 @@ module Fp
68
70
  # List projects
69
71
  fp projects
70
72
 
73
+ # List the surfaces a project tracks (requirements may only name these)
74
+ fp surfaces --project stowzilla
75
+
76
+ # Create a surface (agents can do this — no need to wait on the web app)
77
+ fp surfaces add customer_android --project stowzilla \\
78
+ --name "Customer Android App" \\
79
+ --kind android \\
80
+ --repo stowzilla/customer-android
81
+
71
82
  # List requirements for a project
72
83
  fp list --project stowzilla
73
84
 
@@ -89,6 +100,14 @@ module Fp
89
100
  --acceptance "QR code is printed and scannable" \\
90
101
  --category functional
91
102
 
103
+ # Nest a requirement under a parent (--parent takes the parent's SLUG).
104
+ # A child's --required surfaces must be a subset of its parent's.
105
+ fp propose --project stowzilla \\
106
+ --slug print_container_qr_ios \\
107
+ --name "Print QR on iOS" \\
108
+ --parent print_container_qr \\
109
+ --required customer_ios
110
+
92
111
  # Report evidence for a requirement
93
112
  fp report print_container_qr --project stowzilla \\
94
113
  --surface customer_android \\
@@ -109,6 +128,16 @@ module Fp
109
128
  # --base-dir sets where relative test file paths in the report resolve from
110
129
  # Skipped tests are reported as 'stub'; everything else as 'present'.
111
130
 
131
+ # CI ONLY: ingest real pass/fail results. Run in your CI pipeline after
132
+ # the suite (on green AND red builds). Unlike `fp report`, this writes
133
+ # passing/failing evidence and marks vanished-but-previously-passing
134
+ # slugs as failing. This is the half of the loop that turns the matrix
135
+ # circles into ✓ / ✗.
136
+ fp ci-report --junit junit.xml --project stowzilla \\
137
+ --surface api \\
138
+ --ci-url "$CI_RUN_URL"
139
+ # Pass --junit more than once to ingest several reports for one surface.
140
+
112
141
  # Show the parity matrix
113
142
  fp matrix --project stowzilla
114
143
 
@@ -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/commands.rb CHANGED
@@ -7,6 +7,7 @@ require_relative 'commands/list'
7
7
  require_relative 'commands/show'
8
8
  require_relative 'commands/propose'
9
9
  require_relative 'commands/report'
10
+ require_relative 'commands/ci_report'
10
11
  require_relative 'commands/matrix'
11
12
  require_relative 'commands/profile'
12
13
  require_relative 'commands/config_cmd'
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.6'
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.6
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
@@ -95,6 +95,7 @@ files:
95
95
  - lib/fp/client.rb
96
96
  - lib/fp/commands.rb
97
97
  - lib/fp/commands/base.rb
98
+ - lib/fp/commands/ci_report.rb
98
99
  - lib/fp/commands/config_cmd.rb
99
100
  - lib/fp/commands/help.rb
100
101
  - lib/fp/commands/list.rb