featureparity 0.0.5 → 0.0.7

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
@@ -25,7 +25,7 @@ module Fp
25
25
  def run(args)
26
26
  opts, = parse_flags(args, KNOWN_FLAGS)
27
27
 
28
- require_flag(opts, :project)
28
+ require_project!(opts)
29
29
  require_flag(opts, :slug, '--slug is required (immutable identifier for the requirement)')
30
30
  require_flag(opts, :name, '--name is required (human-readable title)')
31
31
 
@@ -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,7 +112,7 @@ 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
@@ -130,7 +132,7 @@ module Fp
130
132
 
131
133
  # Batch-upload evidence from a JUnit XML report.
132
134
  def run_junit(opts)
133
- require_flag(opts, :project)
135
+ require_project!(opts)
134
136
  require_flag(opts, :repo)
135
137
  # --surface is the default for markers that don't pin their own surface
136
138
  # (e.g. `fp:slug@api,web`). It's only strictly required when at least one
@@ -202,6 +204,7 @@ module Fp
202
204
  repo: opts[:repo],
203
205
  state: state
204
206
  }
207
+ params[:line] = binding.line if binding.line
205
208
  params[:pr_url] = opts[:pr] if opts[:pr]
206
209
  params[:sha] = opts[:sha] if opts[:sha]
207
210
  params[:work_item_url] = opts[:work_item] if opts[:work_item]
@@ -232,7 +235,7 @@ module Fp
232
235
  def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
233
236
  data = {
234
237
  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] } },
238
+ 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
239
  skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
237
240
  unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } }
238
241
  }
@@ -241,7 +244,8 @@ module Fp
241
244
  puts "Reported evidence from #{opts[:junit]}:"
242
245
  reported.each do |r|
243
246
  marker = r[:recorded] ? '✓' : '•'
244
- puts " #{marker} #{r[:binding].slug} #{r[:binding].surface} (#{r[:state]}) #{r[:binding].file}"
247
+ loc = r[:binding].line ? "#{r[:binding].file}:#{r[:binding].line}" : r[:binding].file
248
+ puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface} (#{r[:state]}) #{loc}"
245
249
  end
246
250
 
247
251
  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
@@ -105,7 +105,10 @@ module Fp
105
105
  puts " #{surface}:"
106
106
  records.each do |e|
107
107
  puts " Status: #{e['status']}"
108
- puts " File: #{e['source_file']}" if e['source_file']
108
+ if e['source_file']
109
+ loc = e['source_line'] ? "#{e['source_file']}:#{e['source_line']}" : e['source_file']
110
+ puts " File: #{loc}"
111
+ end
109
112
  puts " Repo: #{e['repo']}" if e['repo']
110
113
  puts " PR: #{e['pr_url']}" if e['pr_url']
111
114
  puts " Work: #{e['work_item_url']}" if e['work_item_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,8 +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'
12
+ require_relative 'commands/ci_report'
13
+ require_relative 'commands/check'
14
+ require_relative 'commands/init'
10
15
  require_relative 'commands/matrix'
16
+ require_relative 'commands/export'
11
17
  require_relative 'commands/profile'
12
18
  require_relative 'commands/config_cmd'
13
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
 
data/lib/fp/parity.rb ADDED
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ # Presentation vocabulary for the parity grid.
5
+ #
6
+ # These are the cell states the API's parity report can return, with the glyph and
7
+ # ordering used to render them. This list is a copy of the server's
8
+ # ParityReport::STATE_SEVERITY keys, which is unavoidable across the gem/API boundary
9
+ # — but it is only ever used for *display*: which glyph to draw, and what order to
10
+ # list counts in.
11
+ #
12
+ # The CLI deliberately does not decide what a violation is. The server returns
13
+ # `violations` and `passing` already computed, and `fp check` exits on those. If it
14
+ # decided locally, every repo would enforce whichever gem version its CI installed,
15
+ # and raising a project's level would silently do nothing until every consumer
16
+ # upgraded. So an unknown state arriving from a newer API degrades to a bare glyph
17
+ # here rather than changing any verdict.
18
+ module Parity
19
+ # Worst → best, matching the server's severity order. Used for stable output.
20
+ STATES = %w[failing missing suspect stub present passing].freeze
21
+
22
+ GLYPHS = {
23
+ 'passing' => '✓',
24
+ 'failing' => '✗',
25
+ 'present' => '●',
26
+ 'stub' => '○',
27
+ 'suspect' => '⚠',
28
+ 'missing' => '·'
29
+ }.freeze
30
+
31
+ # Drawn for a surface a requirement does not require — not a state, an absence.
32
+ NOT_REQUIRED = '-'
33
+
34
+ # Marks a requirement whose proposed change is awaiting human review. Not a cell
35
+ # state: the requirement is still measured, and its cells still say what the evidence
36
+ # says. It's a row-level annotation so a reader can tell "uncovered" from "covered,
37
+ # but the wording just moved under the tests".
38
+ UNDER_REVIEW = '✎'
39
+
40
+ # Fallback for a state this gem version doesn't know about.
41
+ UNKNOWN_GLYPH = '?'
42
+
43
+ LEVELS = %w[off regression covered strict].freeze
44
+
45
+ LEGEND = 'Legend: ✓ passing ✗ failing ● present ○ stub ⚠ suspect · missing - not required'
46
+
47
+ # Appended to the legend only when the grid actually contains one, so the common
48
+ # case isn't cluttered by vocabulary nothing on screen uses.
49
+ UNDER_REVIEW_LEGEND = "#{UNDER_REVIEW} awaiting review of a proposed change"
50
+
51
+ def self.glyph(state)
52
+ GLYPHS.fetch(state.to_s, UNKNOWN_GLYPH)
53
+ end
54
+
55
+ # Row-level annotation for a requirement, or nil when there's nothing to say.
56
+ def self.row_marker(row)
57
+ UNDER_REVIEW if row['under_review'] || row[:under_review]
58
+ end
59
+
60
+ # "2 modifications, 1 deletion" for a pending_reviews list.
61
+ def self.pending_summary(pending_reviews)
62
+ kinds = Array(pending_reviews).map { |p| p['kind'] || p[:kind] }
63
+ removals = kinds.count('removal')
64
+ modifications = kinds.size - removals
65
+
66
+ parts = []
67
+ parts << "#{modifications} modification#{'s' unless modifications == 1}" if modifications.positive?
68
+ parts << "#{removals} deletion#{'s' unless removals == 1}" if removals.positive?
69
+ parts.join(', ')
70
+ end
71
+
72
+ # State counts in severity order, skipping zeros, as "✗ 2 failing" strings.
73
+ def self.count_labels(summary)
74
+ STATES.filter_map do |state|
75
+ count = summary[state].to_i
76
+ "#{glyph(state)} #{count} #{state}" if count.positive?
77
+ end
78
+ end
79
+ end
80
+ end