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.
@@ -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 (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.
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