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.
@@ -12,15 +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)
27
+ ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
28
+ check Enforce parity; exits non-zero on violations
23
29
  matrix --project <slug> Show the parity matrix
30
+ export --project <slug> Export all requirements as a Markdown document
24
31
  profile add|list Manage named profiles
25
32
  config set|get|unset|list Manage global settings
26
33
  whoami Show the resolved config (profile, env, API URL, key)
@@ -35,6 +42,25 @@ module Fp
35
42
  -p <name> Alias for --profile
36
43
  --api-url <url> Override API URL for this command
37
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
+
38
64
  AUTHENTICATION
39
65
  Set FP_API_KEY environment variable, or use a profile:
40
66
 
@@ -58,6 +84,20 @@ module Fp
58
84
  # First-time setup (interactive)
59
85
  fp setup
60
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
+
61
101
  # Show the resolved configuration (which profile/env/API URL/key is in effect)
62
102
  fp whoami
63
103
  fp whoami --json
@@ -84,7 +124,8 @@ module Fp
84
124
  # List requirements filtered by category
85
125
  fp list --project stowzilla --category security
86
126
 
87
- # Show gaps (requirements without evidence)
127
+ # Show gaps: requirements with a required surface that has no real evidence
128
+ # (missing, stubbed, or failing). Unlike `fp list`, this reads evidence.
88
129
  fp list --project stowzilla --gaps --surface customer_android
89
130
 
90
131
  # Show a specific requirement
@@ -107,10 +148,30 @@ module Fp
107
148
  --parent print_container_qr \\
108
149
  --required customer_ios
109
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
+
110
170
  # Report evidence for a requirement
111
171
  fp report print_container_qr --project stowzilla \\
112
172
  --surface customer_android \\
113
173
  --file app/src/test/java/PrintQrTest.kt \\
174
+ --line 42 \\
114
175
  --repo stowzilla/customer-android \\
115
176
  --pr https://github.com/stowzilla/customer-android/pull/123 \\
116
177
  --work-item https://app.fizzy.do/123/cards/456 \\
@@ -127,12 +188,29 @@ module Fp
127
188
  # --base-dir sets where relative test file paths in the report resolve from
128
189
  # Skipped tests are reported as 'stub'; everything else as 'present'.
129
190
 
191
+ # CI ONLY: ingest real pass/fail results. Run in your CI pipeline after
192
+ # the suite (on green AND red builds). Unlike `fp report`, this writes
193
+ # passing/failing evidence and marks vanished-but-previously-passing
194
+ # slugs as failing. This is the half of the loop that turns the matrix
195
+ # circles into ✓ / ✗.
196
+ fp ci-report --junit junit.xml --project stowzilla \\
197
+ --surface api \\
198
+ --ci-url "$CI_RUN_URL"
199
+ # Pass --junit more than once to ingest several reports for one surface.
200
+
130
201
  # Show the parity matrix
131
202
  fp matrix --project stowzilla
132
203
 
133
204
  # Export matrix as CSV
134
205
  fp matrix --project stowzilla --csv > matrix.csv
135
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
+
136
214
  # Map a surface to a local repo on this machine
137
215
  fp repos set customer_android ~/code/customer-android \\
138
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