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.
- checksums.yaml +4 -4
- data/lib/fp/cli.rb +8 -2
- data/lib/fp/client.rb +62 -16
- data/lib/fp/commands/base.rb +49 -0
- data/lib/fp/commands/check.rb +216 -0
- data/lib/fp/commands/ci_report.rb +14 -25
- data/lib/fp/commands/export.rb +144 -0
- data/lib/fp/commands/help.rb +93 -4
- data/lib/fp/commands/history.rb +188 -0
- data/lib/fp/commands/init.rb +397 -0
- data/lib/fp/commands/list.rb +118 -33
- data/lib/fp/commands/matrix.rb +82 -42
- data/lib/fp/commands/propose.rb +10 -2
- data/lib/fp/commands/propose_change.rb +181 -0
- data/lib/fp/commands/report.rb +13 -6
- data/lib/fp/commands/repos.rb +3 -3
- data/lib/fp/commands/show.rb +8 -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 +81 -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 +124 -3
- data/skills/fp/references/cli.md +216 -10
- metadata +9 -2
data/lib/fp/commands/help.rb
CHANGED
|
@@ -12,16 +12,23 @@ 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
|
+
ci-report --junit <file> --pr URL Ingest a PR's CI results as pr_passing — CI only
|
|
29
|
+
check Enforce parity; exits non-zero on violations
|
|
24
30
|
matrix --project <slug> Show the parity matrix
|
|
31
|
+
export --project <slug> Export all requirements as a Markdown document
|
|
25
32
|
profile add|list Manage named profiles
|
|
26
33
|
config set|get|unset|list Manage global settings
|
|
27
34
|
whoami Show the resolved config (profile, env, API URL, key)
|
|
@@ -36,6 +43,33 @@ module Fp
|
|
|
36
43
|
-p <name> Alias for --profile
|
|
37
44
|
--api-url <url> Override API URL for this command
|
|
38
45
|
|
|
46
|
+
PROJECT RESOLUTION
|
|
47
|
+
Commands that act on a project take --project <slug>. If you omit it, fp reads
|
|
48
|
+
`project:` from the nearest .featureparity.yml at or above the working directory,
|
|
49
|
+
so a checkout declares its own project once instead of every CI job and agent
|
|
50
|
+
repeating it. `fp init` writes that file.
|
|
51
|
+
|
|
52
|
+
ENFORCEMENT
|
|
53
|
+
`fp check` fails a build when the project violates its enforcement level. Make it
|
|
54
|
+
a required status check on your default branch to block merges.
|
|
55
|
+
|
|
56
|
+
off Report only; nothing fails.
|
|
57
|
+
regression A test that has gone red fails the build. Missing coverage does not.
|
|
58
|
+
covered Every required surface needs real (non-stub) evidence.
|
|
59
|
+
strict Every required surface must be green in CI.
|
|
60
|
+
|
|
61
|
+
The level lives on the project server-side, so raising it takes effect everywhere
|
|
62
|
+
at once. `--level` overrides it for one run; `--warn-only` reports violations but
|
|
63
|
+
still exits 0, for adopting the gate on a repo that isn't clean yet.
|
|
64
|
+
|
|
65
|
+
PR-VERIFIED EVIDENCE
|
|
66
|
+
`ci-report --pr <url>` marks a run as a pull request's CI. Passing tests are then
|
|
67
|
+
recorded as `pr_passing` ("would pass once merged") instead of `passing`, and no
|
|
68
|
+
merged evidence is demoted. It ranks between `present` and `passing`: it counts as
|
|
69
|
+
covered at the `covered` level, but only `strict` accepts it — strict wants the
|
|
70
|
+
merged-green truth. Run ci-report *without* --pr on a push to the default branch to
|
|
71
|
+
record the real `passing`, which supersedes any pr_passing a prior PR left behind.
|
|
72
|
+
|
|
39
73
|
AUTHENTICATION
|
|
40
74
|
Set FP_API_KEY environment variable, or use a profile:
|
|
41
75
|
|
|
@@ -59,6 +93,20 @@ module Fp
|
|
|
59
93
|
# First-time setup (interactive)
|
|
60
94
|
fp setup
|
|
61
95
|
|
|
96
|
+
# Onboard a repo onto FeatureParity: writes .featureparity.yml, a CI gate
|
|
97
|
+
# workflow, and a FeatureParity section in AGENTS.md telling agents to mark
|
|
98
|
+
# and report their tests. Needs no API key.
|
|
99
|
+
fp init --project stowzilla --surface api
|
|
100
|
+
fp init --project stowzilla --no-workflow # skip the CI workflow
|
|
101
|
+
fp init --project stowzilla --force # refresh previously written files
|
|
102
|
+
|
|
103
|
+
# Enforce parity. Exits 1 on violations, so CI blocks the merge.
|
|
104
|
+
fp check # project from .featureparity.yml
|
|
105
|
+
fp check --project stowzilla --surface api # gate one surface only
|
|
106
|
+
fp check --level covered # demand coverage, not just no regressions
|
|
107
|
+
fp check --warn-only # report violations but exit 0
|
|
108
|
+
fp check --json # machine-readable violations
|
|
109
|
+
|
|
62
110
|
# Show the resolved configuration (which profile/env/API URL/key is in effect)
|
|
63
111
|
fp whoami
|
|
64
112
|
fp whoami --json
|
|
@@ -85,20 +133,34 @@ module Fp
|
|
|
85
133
|
# List requirements filtered by category
|
|
86
134
|
fp list --project stowzilla --category security
|
|
87
135
|
|
|
88
|
-
#
|
|
136
|
+
# List requirements filtered by tag (free-form label)
|
|
137
|
+
fp list --project stowzilla --tag mobile
|
|
138
|
+
|
|
139
|
+
# Show gaps: requirements with a required surface that has no real evidence
|
|
140
|
+
# (missing, stubbed, or failing). Unlike `fp list`, this reads evidence.
|
|
89
141
|
fp list --project stowzilla --gaps --surface customer_android
|
|
90
142
|
|
|
91
143
|
# Show a specific requirement
|
|
92
144
|
fp show print_container_qr --project stowzilla
|
|
93
145
|
|
|
94
146
|
# Propose a new requirement (creates as draft)
|
|
147
|
+
#
|
|
148
|
+
# --why must be brief and actually explain why: whoever approves it should grasp
|
|
149
|
+
# in one read the user need or outcome the feature serves. Longer is fine when it
|
|
150
|
+
# earns its length; wordy is not. Avoid filler, and avoid describing HOW it works
|
|
151
|
+
# instead of WHY anyone wants it.
|
|
152
|
+
# good: "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."
|
|
153
|
+
# bad: "Ensures a high-quality experience across all surfaces." (filler)
|
|
154
|
+
# --acceptance is the observable condition that proves it's done, not a
|
|
155
|
+
# restatement of the title.
|
|
95
156
|
fp propose --project stowzilla \\
|
|
96
157
|
--slug print_container_qr \\
|
|
97
158
|
--name "Print QR code on container label" \\
|
|
98
|
-
--why "
|
|
159
|
+
--why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \\
|
|
99
160
|
--required api,customer_android,customer_ios \\
|
|
100
|
-
--acceptance "QR
|
|
101
|
-
--category functional
|
|
161
|
+
--acceptance "QR scans to the container's detail page" \\
|
|
162
|
+
--category functional \\
|
|
163
|
+
--tags mobile,labeling,q3
|
|
102
164
|
|
|
103
165
|
# Nest a requirement under a parent (--parent takes the parent's SLUG).
|
|
104
166
|
# A child's --required surfaces must be a subset of its parent's.
|
|
@@ -108,10 +170,30 @@ module Fp
|
|
|
108
170
|
--parent print_container_qr \\
|
|
109
171
|
--required customer_ios
|
|
110
172
|
|
|
173
|
+
# Propose a change to a requirement that already exists. Say what prompted it
|
|
174
|
+
# — the card, the thread, the issue. The requirement drops back to DRAFT and a
|
|
175
|
+
# human reviews the old-vs-new diff before it counts again.
|
|
176
|
+
fp propose-change print_container_qr --project stowzilla \\
|
|
177
|
+
--why "Labels must survive a freezer" \\
|
|
178
|
+
--acceptance "QR scans after 24h at -20C" \\
|
|
179
|
+
--reason "Cold-chain rollout" \\
|
|
180
|
+
--source-type fizzy \\
|
|
181
|
+
--source-url https://app.fizzy.do/6098707/cards/1377
|
|
182
|
+
|
|
183
|
+
# Propose deleting a requirement (nothing is destroyed — a human decides)
|
|
184
|
+
fp propose-change print_container_qr --project stowzilla \\
|
|
185
|
+
--removal \\
|
|
186
|
+
--reason "Superseded by print_container_label"
|
|
187
|
+
|
|
188
|
+
# See what changed, why, and who asked for it
|
|
189
|
+
fp history print_container_qr --project stowzilla
|
|
190
|
+
fp history print_container_qr --project stowzilla --pending
|
|
191
|
+
|
|
111
192
|
# Report evidence for a requirement
|
|
112
193
|
fp report print_container_qr --project stowzilla \\
|
|
113
194
|
--surface customer_android \\
|
|
114
195
|
--file app/src/test/java/PrintQrTest.kt \\
|
|
196
|
+
--line 42 \\
|
|
115
197
|
--repo stowzilla/customer-android \\
|
|
116
198
|
--pr https://github.com/stowzilla/customer-android/pull/123 \\
|
|
117
199
|
--work-item https://app.fizzy.do/123/cards/456 \\
|
|
@@ -144,6 +226,13 @@ module Fp
|
|
|
144
226
|
# Export matrix as CSV
|
|
145
227
|
fp matrix --project stowzilla --csv > matrix.csv
|
|
146
228
|
|
|
229
|
+
# Export the full requirements document as Markdown
|
|
230
|
+
fp export --project stowzilla > requirements.md
|
|
231
|
+
|
|
232
|
+
# Export only active requirements for one surface, written to a file
|
|
233
|
+
fp export --project stowzilla --status active \\
|
|
234
|
+
--surface customer_android --output requirements.md
|
|
235
|
+
|
|
147
236
|
# Map a surface to a local repo on this machine
|
|
148
237
|
fp repos set customer_android ~/code/customer-android \\
|
|
149
238
|
--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
|