featureparity 0.0.6 → 0.0.8

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.
@@ -2,75 +2,115 @@
2
2
 
3
3
  module Fp
4
4
  module Commands
5
- # fp matrix --project <slug> [--csv]
6
- # Show the requirements traceability matrix
5
+ # fp matrix [--project X] [--surface api] [--level covered] [--csv]
6
+ #
7
+ # The parity matrix: active requirements down the side, surfaces across the top,
8
+ # each cell showing the state of the evidence filed for that pair.
9
+ #
10
+ # This used to be computed here from `required_surfaces` alone and never read
11
+ # Evidence at all — every required cell rendered as "no evidence yet" and the legend
12
+ # advertised a ✅ the code could not produce. The grid now comes from the API's
13
+ # parity report, which is the same computation the CI gate blocks on, so what you
14
+ # see here is what CI will decide.
7
15
  class Matrix < Base
8
16
  KNOWN_FLAGS = {
9
17
  project: :string,
18
+ surface: :string,
19
+ level: :string,
10
20
  csv: :boolean
11
21
  }.freeze
12
22
 
13
23
  def run(args)
14
24
  opts, = parse_flags(args, KNOWN_FLAGS)
15
- require_flag(opts, :project)
25
+ project_config = ProjectConfig.discover
26
+ project_slug = require_project!(opts, project_config)
27
+ project_id = resolve_project_id(project_slug)
16
28
 
17
- project_id = resolve_project_id(opts[:project])
18
-
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
- exit 1
24
- end
25
-
26
- surfaces = (surfaces_result[:data]['surfaces'] || []).map { |s| s['key'] }
27
-
28
- # Get active requirements
29
- req_result = client.list_requirements(project_id: project_id)
30
- unless req_result[:ok]
31
- output.error(req_result[:error], status: req_result[:status])
29
+ result = client.get_parity(
30
+ project_id,
31
+ level: opts[:level] || project_config.enforcement,
32
+ surface: opts[:surface] || project_config.surface
33
+ )
34
+ unless result[:ok]
35
+ output.error(result[:error], status: result[:status])
32
36
  exit 1
33
37
  end
34
38
 
35
- requirements = (req_result[:data]['requirements'] || []).select { |r| r['status'] == 'active' }
39
+ report = result[:data]['parity'] || {}
40
+ surfaces = report['surfaces'] || []
41
+ requirements = report['requirements'] || []
36
42
 
37
43
  if requirements.empty?
38
- output.success({ matrix: [], surfaces: surfaces }) do
44
+ output.success(report.merge('project' => project_slug)) do
39
45
  puts 'No active requirements. The matrix is empty.'
46
+ puts "Surfaces: #{surfaces.join(', ')}" unless surfaces.empty?
40
47
  end
41
48
  return
42
49
  end
43
50
 
44
- # Build matrix data
45
- # Columns: SLUG, TITLE, then one column per surface
46
51
  headers = %w[SLUG TITLE] + surfaces
47
52
 
48
- rows = requirements.map do |req|
49
- required = req['required_surfaces'] || []
50
- # Evidence status per surface (placeholder until Evidence API exists)
51
- surface_cells = surfaces.map do |s|
52
- if required.include?(s)
53
- '⬜' # Required but no evidence yet
54
- else
55
- '-' # Not required
56
- end
57
- end
58
- [req['slug'], truncate(req['title'], 30)] + surface_cells
59
- end
60
-
61
53
  if opts[:csv]
62
- output.csv(headers, rows)
54
+ # CSV consumers want the state name, not a glyph they'd have to decode.
55
+ output.csv(headers, requirements.map { |req| build_row(req, surfaces, glyphs: false) })
63
56
  else
64
- output.success({ requirements: requirements, surfaces: surfaces, project: opts[:project] }) do
65
- puts "Parity Matrix: #{opts[:project]}"
66
- puts
67
- output.table(headers, rows)
68
- puts
69
- puts 'Legend: ✅ = passing, ⬜ = required (no evidence), - = not required'
57
+ rows = requirements.map { |req| build_row(req, surfaces) }
58
+ render_table(report, project_slug, headers, rows)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def build_row(requirement, surfaces, glyphs: true)
65
+ by_surface = (requirement['cells'] || []).to_h { |cell| [cell['surface'], cell['state']] }
66
+
67
+ cells = surfaces.map do |key|
68
+ state = by_surface[key]
69
+ if state.nil?
70
+ glyphs ? Parity::NOT_REQUIRED : 'not_required'
71
+ else
72
+ glyphs ? Parity.glyph(state) : state
70
73
  end
71
74
  end
75
+
76
+ [slug_cell(requirement, glyphs: glyphs), truncate(requirement['title'], 30)] + cells
77
+ end
78
+
79
+ # A requirement awaiting review of a proposed change is still measured, so it must
80
+ # still appear — but it reads as an ordinary row unless we say so, and its evidence
81
+ # has just been marked suspect. CSV gets a column-safe suffix rather than a glyph.
82
+ def slug_cell(requirement, glyphs: true)
83
+ return requirement['slug'] unless Parity.row_marker(requirement)
84
+
85
+ glyphs ? "#{requirement['slug']} #{Parity::UNDER_REVIEW}" : "#{requirement['slug']} (under review)"
72
86
  end
73
87
 
88
+ def render_table(report, project_slug, headers, rows)
89
+ output.success(report.merge('project' => project_slug)) do
90
+ scope = report['surface'] ? " (surface: #{report['surface']})" : ''
91
+ puts "Parity Matrix: #{project_slug}#{scope}"
92
+ puts
93
+ output.table(headers, rows)
94
+ puts
95
+ legend = Parity::LEGEND
96
+ pending = report['pending_reviews'] || []
97
+ legend += " #{Parity::UNDER_REVIEW_LEGEND}" unless pending.empty?
98
+ puts legend
99
+
100
+ summary = report['summary'] || {}
101
+ counts = Parity.count_labels(summary)
102
+ puts counts.join(' ') unless counts.empty?
103
+
104
+ unless pending.empty?
105
+ puts "#{pending.size} awaiting review (#{Parity.pending_summary(pending)}) — fp history <slug>"
106
+ end
107
+
108
+ # The matrix is a report, not a gate — but say which way the gate would go, so
109
+ # nobody has to run `fp check` separately to find out.
110
+ verdict = report['passing'] ? 'passing' : "FAILING (#{summary['violations']} violation(s))"
111
+ puts "Gate at level '#{report['level']}': #{verdict}"
112
+ end
113
+ end
74
114
  end
75
115
  end
76
116
  end
@@ -16,7 +16,8 @@ module Fp
16
16
  category: :string,
17
17
  source_type: :string,
18
18
  source_url: :string,
19
- source_context: :string
19
+ source_context: :string,
20
+ tags: :string
20
21
  }.freeze
21
22
 
22
23
  VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
@@ -25,7 +26,7 @@ module Fp
25
26
  def run(args)
26
27
  opts, = parse_flags(args, KNOWN_FLAGS)
27
28
 
28
- require_flag(opts, :project)
29
+ require_project!(opts)
29
30
  require_flag(opts, :slug, '--slug is required (immutable identifier for the requirement)')
30
31
  require_flag(opts, :name, '--name is required (human-readable title)')
31
32
 
@@ -62,6 +63,12 @@ module Fp
62
63
  params[:source_url] = opts[:source_url] if opts[:source_url]
63
64
  params[:source_context] = opts[:source_context] if opts[:source_context]
64
65
 
66
+ # Tags are comma-separated. Send the raw list — the API normalizes
67
+ # (downcase/trim/dedupe) and validates.
68
+ if opts[:tags]
69
+ params[:tags] = opts[:tags].split(',').map(&:strip).reject(&:empty?)
70
+ end
71
+
65
72
  # Resolve parent slug to parent_id if provided
66
73
  if opts[:parent]
67
74
  parent_id = resolve_parent_id(project_id, opts[:parent])
@@ -85,6 +92,7 @@ module Fp
85
92
  puts " Title: #{requirement['title']}"
86
93
  puts " Status: #{requirement['status']}"
87
94
  puts " Category: #{requirement['category'] || '(none)'}"
95
+ puts " Tags: #{(requirement['tags'] || []).join(', ')}" unless (requirement['tags'] || []).empty?
88
96
  puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
89
97
  puts " Parent: #{opts[:parent] || '(none)'}" if opts[:parent]
90
98
  puts
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # `fp propose-change <slug>` — propose a modification to an existing requirement, or
6
+ # propose deleting it with --removal.
7
+ #
8
+ # Distinct from `fp propose`, which creates a brand new requirement. This one asks to
9
+ # change one that already exists, and the API records what it was, what is being
10
+ # asked for, and what prompted the ask. The requirement drops back to draft until a
11
+ # human reviews the diff — agents never approve their own changes.
12
+ class ProposeChange < Base
13
+ KNOWN_FLAGS = {
14
+ project: :string, name: :string, why: :string, acceptance: :string,
15
+ required: :string, category: :string, parent: :string, slug: :string,
16
+ reason: :string, source_type: :string, source_url: :string, source_context: :string,
17
+ removal: :boolean
18
+ }.freeze
19
+
20
+ VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
21
+ VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
22
+
23
+ # Flags that describe a proposed new value, mapped to their API field.
24
+ FIELD_FLAGS = {
25
+ name: :title,
26
+ why: :why,
27
+ acceptance: :acceptance,
28
+ category: :category,
29
+ slug: :slug
30
+ }.freeze
31
+
32
+ def run(args)
33
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
34
+ require_project!(opts)
35
+
36
+ slug = positional.first
37
+ unless slug
38
+ output.error('Requirement slug is required. Usage: fp propose-change <slug> [--project <project>] [flags]')
39
+ exit 1
40
+ end
41
+
42
+ validate_enums!(opts)
43
+
44
+ project_id = resolve_project_id(opts[:project])
45
+ requirement = find_requirement!(project_id, slug)
46
+
47
+ params = build_params(opts, project_id)
48
+ removal = opts[:removal] ? true : false
49
+
50
+ if !removal && params.keys.none? { |k| proposed_field?(k) }
51
+ output.error('Nothing to propose. Pass at least one of --name/--why/--acceptance/' \
52
+ '--required/--category/--parent, or --removal to propose deletion.')
53
+ exit 1
54
+ end
55
+
56
+ result = client.propose_requirement_change(requirement['id'], params)
57
+ unless result[:ok]
58
+ output.error(result[:error], status: result[:status])
59
+ exit 1
60
+ end
61
+
62
+ print_result(result[:data], slug: slug, removal: removal)
63
+ end
64
+
65
+ private
66
+
67
+ def validate_enums!(opts)
68
+ if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
69
+ output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")
70
+ exit 1
71
+ end
72
+
73
+ return unless opts[:source_type] && !VALID_SOURCE_TYPES.include?(opts[:source_type])
74
+
75
+ output.error("Invalid source type '#{opts[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}")
76
+ exit 1
77
+ end
78
+
79
+ def find_requirement!(project_id, slug)
80
+ result = client.find_requirement_by_slug(project_id, slug)
81
+ unless result[:ok]
82
+ output.error(result[:error], status: result[:status])
83
+ exit 1
84
+ end
85
+ result[:data]['requirement']
86
+ end
87
+
88
+ def build_params(opts, project_id)
89
+ params = {}
90
+
91
+ FIELD_FLAGS.each { |flag, field| params[field] = opts[flag] if opts[flag] }
92
+ params[:required_surfaces] = opts[:required].split(',').map(&:strip) if opts[:required]
93
+ params[:parent_id] = resolve_parent_id(project_id, opts[:parent]) if opts[:parent]
94
+
95
+ params[:removal] = true if opts[:removal]
96
+ params[:reason] = opts[:reason] if opts[:reason]
97
+ params[:source_type] = opts[:source_type] if opts[:source_type]
98
+ params[:source_url] = opts[:source_url] if opts[:source_url]
99
+ params[:source_context] = opts[:source_context] if opts[:source_context]
100
+
101
+ params
102
+ end
103
+
104
+ def proposed_field?(key)
105
+ %i[title why acceptance category slug required_surfaces parent_id].include?(key)
106
+ end
107
+
108
+ def resolve_parent_id(project_id, parent_slug)
109
+ result = client.list_requirements(project_id: project_id)
110
+ unless result[:ok]
111
+ output.error("Failed to resolve parent slug '#{parent_slug}': #{result[:error]}")
112
+ exit 1
113
+ end
114
+
115
+ parent = (result[:data]['requirements'] || []).find { |r| r['slug'] == parent_slug }
116
+ unless parent
117
+ output.error("Parent requirement '#{parent_slug}' not found in this project")
118
+ exit 1
119
+ end
120
+
121
+ parent['id']
122
+ end
123
+
124
+ def print_result(data, slug:, removal:)
125
+ change = data['change'] || {}
126
+ requirement = data['requirement'] || {}
127
+
128
+ output.success(data) do
129
+ if removal
130
+ puts "Proposed DELETING requirement '#{slug}'."
131
+ else
132
+ puts "Proposed a change to requirement '#{slug}'."
133
+ end
134
+ puts
135
+
136
+ print_diff(change['changes'])
137
+ print_source(change)
138
+
139
+ puts " Status: #{requirement['status']}"
140
+ puts " Change: #{change['id']}"
141
+ puts
142
+ puts '⚠️ This requirement is back in DRAFT pending human review.'
143
+ puts " Run `fp history #{slug} --project <project>` to see the full diff."
144
+ puts ' A human must approve or reject it in the web app. Agents cannot review changes.'
145
+ end
146
+ end
147
+
148
+ def print_diff(changes)
149
+ return if changes.nil? || changes.empty?
150
+
151
+ puts 'Proposed changes:'
152
+ changes.each do |field, change|
153
+ puts " #{field}:"
154
+ puts " - #{format_value(change['from'])}"
155
+ puts " + #{format_value(change['to'])}"
156
+ end
157
+ puts
158
+ end
159
+
160
+ def print_source(change)
161
+ source = change['source'] || {}
162
+ return if source.empty? && change['reason'].nil?
163
+
164
+ puts 'Prompted by:'
165
+ puts " Reason: #{change['reason']}" if change['reason']
166
+ puts " Source: #{source['type']}" if source['type']
167
+ puts " URL: #{source['url']}" if source['url']
168
+ puts " Context: #{source['context']}" if source['context']
169
+ puts
170
+ end
171
+
172
+ def format_value(value)
173
+ case value
174
+ when nil then '(none)'
175
+ when Array then value.empty? ? '(none)' : value.join(', ')
176
+ else value.to_s
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -19,6 +19,7 @@ module Fp
19
19
  project: :string,
20
20
  surface: :string,
21
21
  file: :string,
22
+ line: :string,
22
23
  repo: :string,
23
24
  pr: :string,
24
25
  sha: :string,
@@ -43,7 +44,7 @@ module Fp
43
44
  exit 1
44
45
  end
45
46
 
46
- require_flag(opts, :project)
47
+ require_project!(opts)
47
48
  require_flag(opts, :surface)
48
49
  require_flag(opts, :file)
49
50
  require_flag(opts, :repo)
@@ -79,6 +80,7 @@ module Fp
79
80
  params[:work_item_url] = opts[:work_item] if opts[:work_item]
80
81
  params[:ci_url] = opts[:ci_url] if opts[:ci_url]
81
82
  params[:title] = opts[:title] if opts[:title]
83
+ params[:line] = opts[:line] if opts[:line]
82
84
 
83
85
  # Try to report evidence
84
86
  result = client.report_evidence(params)
@@ -89,7 +91,7 @@ module Fp
89
91
  output.success(params, summary: nil) do
90
92
  puts "Evidence for '#{slug}' on surface '#{opts[:surface]}':"
91
93
  puts " State: #{state}"
92
- puts " File: #{opts[:file]}"
94
+ puts " File: #{opts[:file]}#{opts[:line] ? ":#{opts[:line]}" : ''}"
93
95
  puts " Repo: #{opts[:repo]}"
94
96
  puts " PR: #{opts[:pr]}" if opts[:pr]
95
97
  puts " SHA: #{opts[:sha]}" if opts[:sha]
@@ -110,10 +112,13 @@ module Fp
110
112
  output.success(result[:data]) do
111
113
  puts "Evidence reported for '#{slug}' on surface '#{opts[:surface]}'."
112
114
  puts " State: #{state}"
113
- puts " File: #{opts[:file]}"
115
+ puts " File: #{opts[:file]}#{opts[:line] ? ":#{opts[:line]}" : ''}"
114
116
  if opts[:pr]
115
117
  puts " PR: #{opts[:pr]}"
116
118
  end
119
+ if opts[:sha]
120
+ puts " SHA: #{opts[:sha]}"
121
+ end
117
122
  if opts[:work_item]
118
123
  puts " Work: #{opts[:work_item]}"
119
124
  end
@@ -130,7 +135,7 @@ module Fp
130
135
 
131
136
  # Batch-upload evidence from a JUnit XML report.
132
137
  def run_junit(opts)
133
- require_flag(opts, :project)
138
+ require_project!(opts)
134
139
  require_flag(opts, :repo)
135
140
  # --surface is the default for markers that don't pin their own surface
136
141
  # (e.g. `fp:slug@api,web`). It's only strictly required when at least one
@@ -202,6 +207,7 @@ module Fp
202
207
  repo: opts[:repo],
203
208
  state: state
204
209
  }
210
+ params[:line] = binding.line if binding.line
205
211
  params[:pr_url] = opts[:pr] if opts[:pr]
206
212
  params[:sha] = opts[:sha] if opts[:sha]
207
213
  params[:work_item_url] = opts[:work_item] if opts[:work_item]
@@ -232,7 +238,7 @@ module Fp
232
238
  def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
233
239
  data = {
234
240
  repo: opts[:repo],
235
- reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, file: r[:binding].file, state: r[:state], recorded: r[:recorded] } },
241
+ reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded] } },
236
242
  skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
237
243
  unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } }
238
244
  }
@@ -241,7 +247,8 @@ module Fp
241
247
  puts "Reported evidence from #{opts[:junit]}:"
242
248
  reported.each do |r|
243
249
  marker = r[:recorded] ? '✓' : '•'
244
- puts " #{marker} #{r[:binding].slug} #{r[:binding].surface} (#{r[:state]}) #{r[:binding].file}"
250
+ loc = r[:binding].line ? "#{r[:binding].file}:#{r[:binding].line}" : r[:binding].file
251
+ puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface} (#{r[:state]}) #{loc}"
245
252
  end
246
253
 
247
254
  unless skipped_unknown.empty?
@@ -37,7 +37,7 @@ module Fp
37
37
 
38
38
  def set_repo(args)
39
39
  opts, positional = parse_flags(args, KNOWN_FLAGS)
40
- require_flag(opts, :project)
40
+ require_project!(opts)
41
41
 
42
42
  surface = positional[0]
43
43
  path = positional[1]
@@ -65,7 +65,7 @@ module Fp
65
65
 
66
66
  def get_repo(args)
67
67
  opts, positional = parse_flags(args, KNOWN_FLAGS)
68
- require_flag(opts, :project)
68
+ require_project!(opts)
69
69
 
70
70
  surface = positional[0]
71
71
  unless surface
@@ -123,7 +123,7 @@ module Fp
123
123
 
124
124
  def unset_repo(args)
125
125
  opts, positional = parse_flags(args, KNOWN_FLAGS)
126
- require_flag(opts, :project)
126
+ require_project!(opts)
127
127
 
128
128
  surface = positional[0]
129
129
  unless surface
@@ -7,7 +7,7 @@ module Fp
7
7
  class Show < Base
8
8
  def run(args)
9
9
  opts, positional = parse_flags(args)
10
- require_flag(opts, :project)
10
+ require_project!(opts)
11
11
 
12
12
  slug = positional.first
13
13
  unless slug
@@ -67,6 +67,8 @@ module Fp
67
67
  puts "Slug: #{req['slug']}"
68
68
  puts "Status: #{req['status']}"
69
69
  puts "Category: #{req['category'] || '(none)'}"
70
+ tags = req['tags'] || []
71
+ puts "Tags: #{tags.empty? ? '(none)' : tags.join(', ')}"
70
72
  puts "Parent: #{parent_slug || req['parent_id'] || '(none)'}"
71
73
  puts
72
74
 
@@ -105,8 +107,12 @@ module Fp
105
107
  puts " #{surface}:"
106
108
  records.each do |e|
107
109
  puts " Status: #{e['status']}"
108
- puts " File: #{e['source_file']}" if e['source_file']
110
+ if e['source_file']
111
+ loc = e['source_line'] ? "#{e['source_file']}:#{e['source_line']}" : e['source_file']
112
+ puts " File: #{loc}"
113
+ end
109
114
  puts " Repo: #{e['repo']}" if e['repo']
115
+ puts " Commit: #{e['commit_sha']}" if e['commit_sha']
110
116
  puts " PR: #{e['pr_url']}" if e['pr_url']
111
117
  puts " Work: #{e['work_item_url']}" if e['work_item_url']
112
118
  puts " CI: #{e['ci_url']}" if e['ci_url']
@@ -35,7 +35,7 @@ module Fp
35
35
 
36
36
  def list(args)
37
37
  opts, = parse_flags(args)
38
- require_flag(opts, :project)
38
+ require_project!(opts)
39
39
 
40
40
  project_id = resolve_project_id(opts[:project])
41
41
  result = client.list_surfaces(project_id)
@@ -61,7 +61,7 @@ module Fp
61
61
 
62
62
  def add(args)
63
63
  opts, positional = parse_flags(args, ADD_FLAGS)
64
- require_flag(opts, :project)
64
+ require_project!(opts)
65
65
 
66
66
  key = positional.first
67
67
  if key.nil? || key.strip.empty?
data/lib/fp/commands.rb CHANGED
@@ -6,9 +6,14 @@ require_relative 'commands/surfaces'
6
6
  require_relative 'commands/list'
7
7
  require_relative 'commands/show'
8
8
  require_relative 'commands/propose'
9
+ require_relative 'commands/propose_change'
10
+ require_relative 'commands/history'
9
11
  require_relative 'commands/report'
10
12
  require_relative 'commands/ci_report'
13
+ require_relative 'commands/check'
14
+ require_relative 'commands/init'
11
15
  require_relative 'commands/matrix'
16
+ require_relative 'commands/export'
12
17
  require_relative 'commands/profile'
13
18
  require_relative 'commands/config_cmd'
14
19
  require_relative 'commands/repos'
data/lib/fp/junit.rb CHANGED
@@ -21,7 +21,11 @@ module Fp
21
21
  end
22
22
 
23
23
  # Evidence discovered by binding a testcase to a marker.
24
- Binding = Struct.new(:slug, :surface, :file, :title, :state, keyword_init: true)
24
+ # `line` is the 1-based line number of the test in its source file: the
25
+ # testcase's own line when the runner reported one, otherwise the line of
26
+ # the fp:<slug> marker that annotates it (markers sit directly above the
27
+ # test, so the marker line is a close stand-in when the runner omits lines).
28
+ Binding = Struct.new(:slug, :surface, :file, :line, :title, :state, keyword_init: true)
25
29
 
26
30
  # Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
27
31
  # `@surface1,surface2`) suffix to target one or more specific surfaces:
@@ -166,7 +170,7 @@ module Fp
166
170
  if surface.nil? || surface.empty?
167
171
  missing_surface << { slug: marker[:slug], file: tc.file }
168
172
  else
169
- record_binding(bindings, marker[:slug], surface, tc, titles_reliable: titles_reliable)
173
+ record_binding(bindings, marker[:slug], surface, tc, marker, titles_reliable: titles_reliable)
170
174
  end
171
175
  end
172
176
  end
@@ -203,16 +207,21 @@ module Fp
203
207
  candidate
204
208
  end
205
209
 
206
- def record_binding(bindings, slug, surface, testcase, titles_reliable: true)
210
+ def record_binding(bindings, slug, surface, testcase, marker, titles_reliable: true)
207
211
  rel_file = testcase.file
208
212
  key = [slug, surface, rel_file]
209
213
  state = testcase.stub? ? 'stub' : 'present'
210
214
 
215
+ # The test's line: prefer the runner-reported testcase line; fall back to
216
+ # the marker's line (it sits directly above the test) when the runner
217
+ # omitted line info.
218
+ line = testcase.line || (marker && marker[:line])
219
+
211
220
  existing = bindings[key]
212
221
  # Prefer a concrete 'present' state over 'stub' when a slug has both.
213
222
  return if existing && !(existing.state == 'stub' && state == 'present')
214
223
 
215
- bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file,
224
+ bindings[key] = Binding.new(slug: slug, surface: surface, file: rel_file, line: line,
216
225
  title: (testcase.name if titles_reliable), state: state)
217
226
  end
218
227