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/help.rb
CHANGED
|
@@ -12,16 +12,22 @@ module Fp
|
|
|
12
12
|
fp <command> [flags]
|
|
13
13
|
|
|
14
14
|
COMMANDS
|
|
15
|
+
init --project <slug> Onboard this repo: config, CI gate, agent docs
|
|
15
16
|
projects List projects you have access to
|
|
16
17
|
surfaces --project <slug> List surfaces for a project
|
|
17
18
|
surfaces add <key> --project ... Create a surface
|
|
18
19
|
list --project <slug> List active requirements
|
|
19
20
|
show <slug> --project <slug> Show requirement details
|
|
20
21
|
propose --project <slug> ... Propose a new requirement (as draft)
|
|
22
|
+
propose-change <slug> ... Propose a change to an existing requirement
|
|
23
|
+
propose-change <slug> --removal Propose deleting a requirement
|
|
24
|
+
history <slug> --project <slug> Show a requirement's change history + diffs
|
|
21
25
|
report <slug> --project <slug>... Report evidence for a requirement
|
|
22
26
|
report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
|
|
23
27
|
ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
|
|
28
|
+
check Enforce parity; exits non-zero on violations
|
|
24
29
|
matrix --project <slug> Show the parity matrix
|
|
30
|
+
export --project <slug> Export all requirements as a Markdown document
|
|
25
31
|
profile add|list Manage named profiles
|
|
26
32
|
config set|get|unset|list Manage global settings
|
|
27
33
|
whoami Show the resolved config (profile, env, API URL, key)
|
|
@@ -36,6 +42,25 @@ module Fp
|
|
|
36
42
|
-p <name> Alias for --profile
|
|
37
43
|
--api-url <url> Override API URL for this command
|
|
38
44
|
|
|
45
|
+
PROJECT RESOLUTION
|
|
46
|
+
Commands that act on a project take --project <slug>. If you omit it, fp reads
|
|
47
|
+
`project:` from the nearest .featureparity.yml at or above the working directory,
|
|
48
|
+
so a checkout declares its own project once instead of every CI job and agent
|
|
49
|
+
repeating it. `fp init` writes that file.
|
|
50
|
+
|
|
51
|
+
ENFORCEMENT
|
|
52
|
+
`fp check` fails a build when the project violates its enforcement level. Make it
|
|
53
|
+
a required status check on your default branch to block merges.
|
|
54
|
+
|
|
55
|
+
off Report only; nothing fails.
|
|
56
|
+
regression A test that has gone red fails the build. Missing coverage does not.
|
|
57
|
+
covered Every required surface needs real (non-stub) evidence.
|
|
58
|
+
strict Every required surface must be green in CI.
|
|
59
|
+
|
|
60
|
+
The level lives on the project server-side, so raising it takes effect everywhere
|
|
61
|
+
at once. `--level` overrides it for one run; `--warn-only` reports violations but
|
|
62
|
+
still exits 0, for adopting the gate on a repo that isn't clean yet.
|
|
63
|
+
|
|
39
64
|
AUTHENTICATION
|
|
40
65
|
Set FP_API_KEY environment variable, or use a profile:
|
|
41
66
|
|
|
@@ -59,6 +84,20 @@ module Fp
|
|
|
59
84
|
# First-time setup (interactive)
|
|
60
85
|
fp setup
|
|
61
86
|
|
|
87
|
+
# Onboard a repo onto FeatureParity: writes .featureparity.yml, a CI gate
|
|
88
|
+
# workflow, and a FeatureParity section in AGENTS.md telling agents to mark
|
|
89
|
+
# and report their tests. Needs no API key.
|
|
90
|
+
fp init --project stowzilla --surface api
|
|
91
|
+
fp init --project stowzilla --no-workflow # skip the CI workflow
|
|
92
|
+
fp init --project stowzilla --force # refresh previously written files
|
|
93
|
+
|
|
94
|
+
# Enforce parity. Exits 1 on violations, so CI blocks the merge.
|
|
95
|
+
fp check # project from .featureparity.yml
|
|
96
|
+
fp check --project stowzilla --surface api # gate one surface only
|
|
97
|
+
fp check --level covered # demand coverage, not just no regressions
|
|
98
|
+
fp check --warn-only # report violations but exit 0
|
|
99
|
+
fp check --json # machine-readable violations
|
|
100
|
+
|
|
62
101
|
# Show the resolved configuration (which profile/env/API URL/key is in effect)
|
|
63
102
|
fp whoami
|
|
64
103
|
fp whoami --json
|
|
@@ -85,7 +124,8 @@ module Fp
|
|
|
85
124
|
# List requirements filtered by category
|
|
86
125
|
fp list --project stowzilla --category security
|
|
87
126
|
|
|
88
|
-
# Show gaps
|
|
127
|
+
# Show gaps: requirements with a required surface that has no real evidence
|
|
128
|
+
# (missing, stubbed, or failing). Unlike `fp list`, this reads evidence.
|
|
89
129
|
fp list --project stowzilla --gaps --surface customer_android
|
|
90
130
|
|
|
91
131
|
# Show a specific requirement
|
|
@@ -108,10 +148,30 @@ module Fp
|
|
|
108
148
|
--parent print_container_qr \\
|
|
109
149
|
--required customer_ios
|
|
110
150
|
|
|
151
|
+
# Propose a change to a requirement that already exists. Say what prompted it
|
|
152
|
+
# — the card, the thread, the issue. The requirement drops back to DRAFT and a
|
|
153
|
+
# human reviews the old-vs-new diff before it counts again.
|
|
154
|
+
fp propose-change print_container_qr --project stowzilla \\
|
|
155
|
+
--why "Labels must survive a freezer" \\
|
|
156
|
+
--acceptance "QR scans after 24h at -20C" \\
|
|
157
|
+
--reason "Cold-chain rollout" \\
|
|
158
|
+
--source-type fizzy \\
|
|
159
|
+
--source-url https://app.fizzy.do/6098707/cards/1377
|
|
160
|
+
|
|
161
|
+
# Propose deleting a requirement (nothing is destroyed — a human decides)
|
|
162
|
+
fp propose-change print_container_qr --project stowzilla \\
|
|
163
|
+
--removal \\
|
|
164
|
+
--reason "Superseded by print_container_label"
|
|
165
|
+
|
|
166
|
+
# See what changed, why, and who asked for it
|
|
167
|
+
fp history print_container_qr --project stowzilla
|
|
168
|
+
fp history print_container_qr --project stowzilla --pending
|
|
169
|
+
|
|
111
170
|
# Report evidence for a requirement
|
|
112
171
|
fp report print_container_qr --project stowzilla \\
|
|
113
172
|
--surface customer_android \\
|
|
114
173
|
--file app/src/test/java/PrintQrTest.kt \\
|
|
174
|
+
--line 42 \\
|
|
115
175
|
--repo stowzilla/customer-android \\
|
|
116
176
|
--pr https://github.com/stowzilla/customer-android/pull/123 \\
|
|
117
177
|
--work-item https://app.fizzy.do/123/cards/456 \\
|
|
@@ -144,6 +204,13 @@ module Fp
|
|
|
144
204
|
# Export matrix as CSV
|
|
145
205
|
fp matrix --project stowzilla --csv > matrix.csv
|
|
146
206
|
|
|
207
|
+
# Export the full requirements document as Markdown
|
|
208
|
+
fp export --project stowzilla > requirements.md
|
|
209
|
+
|
|
210
|
+
# Export only active requirements for one surface, written to a file
|
|
211
|
+
fp export --project stowzilla --status active \\
|
|
212
|
+
--surface customer_android --output requirements.md
|
|
213
|
+
|
|
147
214
|
# Map a surface to a local repo on this machine
|
|
148
215
|
fp repos set customer_android ~/code/customer-android \\
|
|
149
216
|
--project stowzilla --repo stowzilla/customer-android
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# `fp history <slug>` — the change history of a requirement: what it was, what
|
|
6
|
+
# changed, who changed it, and what prompted the change.
|
|
7
|
+
#
|
|
8
|
+
# Reads as a timeline (oldest first) so it can be scanned top to bottom. The open
|
|
9
|
+
# proposal, if there is one, is called out at the end because that's the thing
|
|
10
|
+
# someone still has to decide on.
|
|
11
|
+
class History < Base
|
|
12
|
+
KNOWN_FLAGS = { project: :string, pending: :boolean, limit: :string }.freeze
|
|
13
|
+
|
|
14
|
+
# Human labels for event types. The wire values are stable identifiers; these are
|
|
15
|
+
# for reading.
|
|
16
|
+
TYPE_LABELS = {
|
|
17
|
+
'created' => 'created',
|
|
18
|
+
'updated' => 'edited',
|
|
19
|
+
'activated' => 'approved',
|
|
20
|
+
'archived' => 'archived',
|
|
21
|
+
'destroyed' => 'deleted',
|
|
22
|
+
'change_proposed' => 'change proposed',
|
|
23
|
+
'removal_proposed' => 'deletion proposed',
|
|
24
|
+
'change_approved' => 'change approved',
|
|
25
|
+
'change_rejected' => 'change rejected'
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def run(args)
|
|
29
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
30
|
+
require_project!(opts)
|
|
31
|
+
|
|
32
|
+
slug = positional.first
|
|
33
|
+
unless slug
|
|
34
|
+
output.error('Requirement slug is required. Usage: fp history <slug> [--project <project>]')
|
|
35
|
+
exit 1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
project_id = resolve_project_id(opts[:project])
|
|
39
|
+
requirement = find_requirement!(project_id, slug)
|
|
40
|
+
|
|
41
|
+
result = client.list_requirement_changes(requirement['id'])
|
|
42
|
+
unless result[:ok]
|
|
43
|
+
output.error(result[:error], status: result[:status])
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
changes = result[:data]['changes'] || []
|
|
48
|
+
pending = result[:data]['pending_change']
|
|
49
|
+
|
|
50
|
+
changes = changes.select { |c| c['pending'] } if opts[:pending]
|
|
51
|
+
changes = changes.last(opts[:limit].to_i) if opts[:limit].to_s =~ /\A\d+\z/
|
|
52
|
+
|
|
53
|
+
payload = { 'requirement' => requirement, 'changes' => changes, 'pending_change' => pending }
|
|
54
|
+
|
|
55
|
+
output.success(payload) do
|
|
56
|
+
print_history(slug, changes, pending)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def find_requirement!(project_id, slug)
|
|
63
|
+
result = client.find_requirement_by_slug(project_id, slug)
|
|
64
|
+
unless result[:ok]
|
|
65
|
+
output.error(result[:error], status: result[:status])
|
|
66
|
+
exit 1
|
|
67
|
+
end
|
|
68
|
+
result[:data]['requirement']
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def print_history(slug, changes, pending)
|
|
72
|
+
heading = "History: #{slug}"
|
|
73
|
+
puts heading
|
|
74
|
+
puts '=' * heading.length
|
|
75
|
+
puts
|
|
76
|
+
|
|
77
|
+
if changes.empty?
|
|
78
|
+
puts ' (no recorded changes)'
|
|
79
|
+
puts
|
|
80
|
+
else
|
|
81
|
+
changes.each { |change| print_entry(change) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
print_pending(pending)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def print_entry(change)
|
|
88
|
+
label = TYPE_LABELS[change['type']] || change['type']
|
|
89
|
+
actor = change['actor'] || 'unknown'
|
|
90
|
+
kind = change['actor_kind'] == 'agent' ? 'agent' : 'human'
|
|
91
|
+
|
|
92
|
+
puts "* #{label} — #{actor} (#{kind}) at #{format_time(change['recorded_at'])}"
|
|
93
|
+
puts " #{change['reason']}" if present?(change['reason'])
|
|
94
|
+
print_source(change['source'])
|
|
95
|
+
print_diff(change['changes'])
|
|
96
|
+
print_evidence_diff(change['evidence_diff'])
|
|
97
|
+
print_review(change['review'])
|
|
98
|
+
puts
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def print_source(source)
|
|
102
|
+
return if source.nil? || source.empty?
|
|
103
|
+
|
|
104
|
+
bits = []
|
|
105
|
+
bits << source['type'] if present?(source['type'])
|
|
106
|
+
bits << source['url'] if present?(source['url'])
|
|
107
|
+
puts " prompted by: #{bits.join(' ')}" unless bits.empty?
|
|
108
|
+
puts " context: #{source['context']}" if present?(source['context'])
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def print_diff(changes)
|
|
112
|
+
return if changes.nil? || changes.empty?
|
|
113
|
+
|
|
114
|
+
changes.each do |field, change|
|
|
115
|
+
puts " #{field}:"
|
|
116
|
+
puts " - #{format_value(change['from'])}"
|
|
117
|
+
puts " + #{format_value(change['to'])}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The test-evidence half of the diff: what coverage existed when the change was
|
|
122
|
+
# proposed versus what exists now (or existed at review time).
|
|
123
|
+
def print_evidence_diff(diff)
|
|
124
|
+
return if diff.nil?
|
|
125
|
+
|
|
126
|
+
added = diff['added'] || []
|
|
127
|
+
removed = diff['removed'] || []
|
|
128
|
+
changed = diff['changed'] || []
|
|
129
|
+
return if added.empty? && removed.empty? && changed.empty?
|
|
130
|
+
|
|
131
|
+
puts ' evidence:'
|
|
132
|
+
added.each { |e| puts " + #{describe_evidence(e)}" }
|
|
133
|
+
removed.each { |e| puts " - #{describe_evidence(e)}" }
|
|
134
|
+
changed.each do |c|
|
|
135
|
+
before = c['before'] || {}
|
|
136
|
+
after = c['after'] || {}
|
|
137
|
+
puts " ~ #{after['surface']} #{after['source_file']} " \
|
|
138
|
+
"(#{before['status']} → #{after['status']})"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def describe_evidence(evidence)
|
|
143
|
+
parts = [evidence['surface'], evidence['source_file']].compact
|
|
144
|
+
status = evidence['status']
|
|
145
|
+
status ? "#{parts.join(' ')} (#{status})" : parts.join(' ')
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def print_review(review)
|
|
149
|
+
return if review.nil? || review.empty?
|
|
150
|
+
|
|
151
|
+
puts " reviewed: #{review['decision']} by #{review['actor']} at #{format_time(review['at'])}"
|
|
152
|
+
puts " note: #{review['note']}" if present?(review['note'])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def print_pending(pending)
|
|
156
|
+
if pending.nil?
|
|
157
|
+
puts 'No change awaiting review.'
|
|
158
|
+
return
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
kind = pending['kind'] == 'removal' ? 'DELETION' : 'CHANGE'
|
|
162
|
+
puts "⚠️ #{kind} AWAITING REVIEW (#{pending['id']})"
|
|
163
|
+
puts " Proposed by #{pending['actor']} at #{format_time(pending['recorded_at'])}."
|
|
164
|
+
puts ' A human must approve or reject it in the web app.'
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Timestamps are recorded at nanosecond precision for ordering; that's noise to
|
|
168
|
+
# read, so trim to seconds.
|
|
169
|
+
def format_time(value)
|
|
170
|
+
return '(unknown)' unless present?(value)
|
|
171
|
+
|
|
172
|
+
value.to_s.sub(/\.\d+/, '')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def format_value(value)
|
|
176
|
+
case value
|
|
177
|
+
when nil then '(none)'
|
|
178
|
+
when Array then value.empty? ? '(none)' : value.join(', ')
|
|
179
|
+
else value.to_s
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def present?(value)
|
|
184
|
+
!value.nil? && !value.to_s.strip.empty?
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|