featureparity 0.0.6 → 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.
- checksums.yaml +4 -4
- data/lib/fp/cli.rb +8 -2
- data/lib/fp/client.rb +50 -9
- data/lib/fp/commands/base.rb +49 -0
- data/lib/fp/commands/check.rb +216 -0
- data/lib/fp/commands/ci_report.rb +1 -21
- data/lib/fp/commands/export.rb +144 -0
- data/lib/fp/commands/help.rb +68 -1
- data/lib/fp/commands/history.rb +188 -0
- data/lib/fp/commands/init.rb +384 -0
- data/lib/fp/commands/list.rb +111 -31
- data/lib/fp/commands/matrix.rb +82 -42
- data/lib/fp/commands/propose.rb +1 -1
- data/lib/fp/commands/propose_change.rb +181 -0
- data/lib/fp/commands/report.rb +10 -6
- data/lib/fp/commands/repos.rb +3 -3
- data/lib/fp/commands/show.rb +5 -2
- data/lib/fp/commands/surfaces.rb +2 -2
- data/lib/fp/commands.rb +5 -0
- data/lib/fp/junit.rb +13 -4
- data/lib/fp/parity.rb +80 -0
- data/lib/fp/project_config.rb +137 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +2 -0
- data/skills/fp/SKILL.md +109 -1
- data/skills/fp/references/cli.md +203 -6
- metadata +9 -2
data/lib/fp/commands/matrix.rb
CHANGED
|
@@ -2,75 +2,115 @@
|
|
|
2
2
|
|
|
3
3
|
module Fp
|
|
4
4
|
module Commands
|
|
5
|
-
# fp matrix --project
|
|
6
|
-
#
|
|
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
|
-
|
|
25
|
+
project_config = ProjectConfig.discover
|
|
26
|
+
project_slug = require_project!(opts, project_config)
|
|
27
|
+
project_id = resolve_project_id(project_slug)
|
|
16
28
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
39
|
+
report = result[:data]['parity'] || {}
|
|
40
|
+
surfaces = report['surfaces'] || []
|
|
41
|
+
requirements = report['requirements'] || []
|
|
36
42
|
|
|
37
43
|
if requirements.empty?
|
|
38
|
-
output.success(
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
data/lib/fp/commands/propose.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Fp
|
|
|
25
25
|
def run(args)
|
|
26
26
|
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
27
27
|
|
|
28
|
-
|
|
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
|
data/lib/fp/commands/report.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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?
|
data/lib/fp/commands/repos.rb
CHANGED
|
@@ -37,7 +37,7 @@ module Fp
|
|
|
37
37
|
|
|
38
38
|
def set_repo(args)
|
|
39
39
|
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
+
require_project!(opts)
|
|
127
127
|
|
|
128
128
|
surface = positional[0]
|
|
129
129
|
unless surface
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Fp
|
|
|
7
7
|
class Show < Base
|
|
8
8
|
def run(args)
|
|
9
9
|
opts, positional = parse_flags(args)
|
|
10
|
-
|
|
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
|
-
|
|
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']
|
data/lib/fp/commands/surfaces.rb
CHANGED
|
@@ -35,7 +35,7 @@ module Fp
|
|
|
35
35
|
|
|
36
36
|
def list(args)
|
|
37
37
|
opts, = parse_flags(args)
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|