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.
@@ -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
- # Show gaps (requirements without evidence)
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 "Customers scan to track containers" \\
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 code is printed and scannable" \\
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