featureparity 0.0.7 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d668266132dc93de82fc60d7603f340ab05a8e5852fa4cde36ce9e6e4586ff18
4
- data.tar.gz: 7d1b2691eeade5eabf110c518dca40808ce1beee2a0809b422376eaf914692c4
3
+ metadata.gz: c622199401f105760e517583ace2bb9ec9da7f6f125d960144783ec82e2922b4
4
+ data.tar.gz: ab0b372febeff5b863f46d80d093ff8ac789bb4fdd2ae9ceab443f81a8b9a2f5
5
5
  SHA512:
6
- metadata.gz: dafb288e14865ffd01b3cf739a510b8c7c2cf103b5e0867b07ce8f79264a14e8ab34ef95a01a73a77c3566adc2ce792488a3581f8ca184f1cd9b0678f8a4bce2
7
- data.tar.gz: eee75e4d691fbd6884b0d417a1e8a9ae8c88e15da95ad1919b64f9a7e6a1ed915a5a8baa2687603cbe9d41a3f27738d4b0ae360d3ec97efb0885c45e62b4bcaf
6
+ metadata.gz: ed416b54e77e1aa6e9ce490283811ac5b81443f34aef97cf9b47987719bc5b5497456c4fd3bb74b1f308b735ab07bc5682a06a4d13548cd48272135160692cd2
7
+ data.tar.gz: 6316ceda063c279ad46dd763c5ab9b1366a27cd00c1eb8d6f52b7ce21995c203040f86667e76a4a921e77d7a14c380ec85aaedb1ad0c4468e8284b185cf69a5c
data/lib/fp/client.rb CHANGED
@@ -33,11 +33,12 @@ module Fp
33
33
  end
34
34
 
35
35
  # GET /requirements
36
- def list_requirements(project_id: nil, status: nil, category: nil)
36
+ def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
37
37
  params = {}
38
38
  params[:project_id] = project_id if project_id
39
39
  params[:status] = status if status
40
40
  params[:category] = category if category
41
+ params[:tag] = tag if tag
41
42
  get('/api/requirements', params)
42
43
  end
43
44
 
@@ -118,12 +119,16 @@ module Fp
118
119
  # writes passing/failing evidence per surface. It also marks previously
119
120
  # passing slugs that have vanished from the results as failing. This is the
120
121
  # half of the loop only CI is allowed to close (#1222).
121
- def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:)
122
- post("/api/projects/#{project_id}/ci_results", {
123
- ci_url: ci_url,
124
- surface: surface,
125
- junit_xml: junit_xml
126
- })
122
+ def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil)
123
+ body = {
124
+ ci_url: ci_url,
125
+ surface: surface,
126
+ junit_xml: junit_xml
127
+ }
128
+ # Only send pr_url when set, so a merged-branch run stays a plain ingest.
129
+ body[:pr_url] = pr_url if pr_url && !pr_url.empty?
130
+
131
+ post("/api/projects/#{project_id}/ci_results", body)
127
132
  end
128
133
 
129
134
  # GET /projects/:project_id/parity — the computed parity grid and gate verdict.
@@ -25,11 +25,18 @@ module Fp
25
25
  # Multiple --junit flags may be passed to ingest several reports for the same
26
26
  # surface in one invocation (e.g. a suite split across shards). Each is
27
27
  # uploaded in turn.
28
+ #
29
+ # --pr <url> marks the run as a pull request's CI rather than the merged branch's.
30
+ # Passing tests are then recorded as `pr_passing` ("would pass once merged") instead
31
+ # of `passing`, and no merged evidence is demoted. Run it without --pr on pushes to
32
+ # the default branch to record the real `passing`, superseding any pr_passing a prior
33
+ # PR run left behind.
28
34
  class CiReport < Base
29
35
  KNOWN_FLAGS = {
30
36
  project: :string,
31
37
  surface: :string,
32
38
  ci_url: :string,
39
+ pr: :string,
33
40
  junit: :string
34
41
  }.freeze
35
42
 
@@ -64,7 +71,8 @@ module Fp
64
71
  project_id,
65
72
  ci_url: opts[:ci_url],
66
73
  surface: opts[:surface],
67
- junit_xml: xml
74
+ junit_xml: xml,
75
+ pr_url: opts[:pr]
68
76
  )
69
77
 
70
78
  unless result[:ok]
@@ -92,6 +100,7 @@ module Fp
92
100
  project: opts[:project],
93
101
  surface: opts[:surface],
94
102
  ci_url: opts[:ci_url],
103
+ pr_url: opts[:pr],
95
104
  evidence_created: totals[:created],
96
105
  evidence_updated: totals[:updated],
97
106
  missing_marked_failing: totals[:missing_failing],
@@ -99,11 +108,11 @@ module Fp
99
108
  }
100
109
 
101
110
  output.success(data) do
102
- puts "Ingested CI results for surface '#{opts[:surface]}':"
111
+ mode = opts[:pr] ? " (PR mode passing tests recorded as pr_passing)" : ''
112
+ puts "Ingested CI results for surface '#{opts[:surface]}'#{mode}:"
103
113
  totals[:reports].each do |report|
104
114
  report[:results].each do |r|
105
- glyph = r['status'] == 'passing' ? '✓' : (r['status'] == 'failing' ? '✗' : '•')
106
- puts " #{glyph} #{r['slug']} (#{r['status']}) #{r['source_file']}"
115
+ puts " #{Parity.glyph(r['status'])} #{r['slug']} (#{r['status']}) #{r['source_file']}"
107
116
  end
108
117
  end
109
118
  puts
@@ -25,6 +25,7 @@ module Fp
25
25
  report <slug> --project <slug>... Report evidence for a requirement
26
26
  report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
27
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
28
29
  check Enforce parity; exits non-zero on violations
29
30
  matrix --project <slug> Show the parity matrix
30
31
  export --project <slug> Export all requirements as a Markdown document
@@ -61,6 +62,14 @@ module Fp
61
62
  at once. `--level` overrides it for one run; `--warn-only` reports violations but
62
63
  still exits 0, for adopting the gate on a repo that isn't clean yet.
63
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
+
64
73
  AUTHENTICATION
65
74
  Set FP_API_KEY environment variable, or use a profile:
66
75
 
@@ -124,6 +133,9 @@ module Fp
124
133
  # List requirements filtered by category
125
134
  fp list --project stowzilla --category security
126
135
 
136
+ # List requirements filtered by tag (free-form label)
137
+ fp list --project stowzilla --tag mobile
138
+
127
139
  # Show gaps: requirements with a required surface that has no real evidence
128
140
  # (missing, stubbed, or failing). Unlike `fp list`, this reads evidence.
129
141
  fp list --project stowzilla --gaps --surface customer_android
@@ -132,13 +144,23 @@ module Fp
132
144
  fp show print_container_qr --project stowzilla
133
145
 
134
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.
135
156
  fp propose --project stowzilla \\
136
157
  --slug print_container_qr \\
137
158
  --name "Print QR code on container label" \\
138
- --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" \\
139
160
  --required api,customer_android,customer_ios \\
140
- --acceptance "QR code is printed and scannable" \\
141
- --category functional
161
+ --acceptance "QR scans to the container's detail page" \\
162
+ --category functional \\
163
+ --tags mobile,labeling,q3
142
164
 
143
165
  # Nest a requirement under a parent (--parent takes the parent's SLUG).
144
166
  # A child's --required surfaces must be a subset of its parent's.
@@ -309,6 +309,19 @@ module Fp
309
309
  --source-type fizzy --source-url <card-or-issue-url>
310
310
  ```
311
311
 
312
+ **Writing the `--why`: brief, and it actually explains why.** Someone approving
313
+ this requirement needs to grasp in one read why the product needs it — the user
314
+ problem it solves or the outcome it protects. State that and stop. Longer is fine
315
+ when it earns its length; wordy is not. Avoid two things: filler that says nothing
316
+ specific, and describing *how* the feature works instead of *why* anyone wants it.
317
+
318
+ - ✅ `--why "Warehouse staff scan the label to look up a container; without a QR code they key in the ID by hand and mis-type it."`
319
+ - ❌ `--why "This is an important requirement that ensures a high-quality experience for our users across all surfaces."` (filler — says nothing specific)
320
+ - ❌ `--why "The label renders a QR image encoding the container ID at print time."` (how it works, not why anyone needs it)
321
+
322
+ Same rule for `--acceptance`: state the observable condition that proves it's done
323
+ (`"QR scans to the container's detail page"`), not a restatement of the title.
324
+
312
325
  ### 2. If the requirement is wrong, propose a change — don't silently edit it
313
326
 
314
327
  Shipping something that contradicts a requirement means one of the two is wrong.
@@ -9,6 +9,7 @@ module Fp
9
9
  project: :string,
10
10
  surface: :string,
11
11
  category: :string,
12
+ tag: :string,
12
13
  status: :string,
13
14
  gaps: :boolean
14
15
  }.freeze
@@ -33,7 +34,7 @@ module Fp
33
34
  private
34
35
 
35
36
  def list_requirements(project_id, opts)
36
- result = client.list_requirements(project_id: project_id, category: opts[:category])
37
+ result = client.list_requirements(project_id: project_id, category: opts[:category], tag: opts[:tag])
37
38
 
38
39
  unless result[:ok]
39
40
  output.error(result[:error], status: result[:status])
@@ -66,6 +67,7 @@ module Fp
66
67
  if requirements.empty?
67
68
  filter_desc = [status_filter]
68
69
  filter_desc << "category: #{opts[:category]}" if opts[:category]
70
+ filter_desc << "tag: #{opts[:tag]}" if opts[:tag]
69
71
  filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
70
72
  puts "No requirements found (#{filter_desc.join(', ')})."
71
73
  else
@@ -79,19 +81,21 @@ module Fp
79
81
  parent_ids = parents.map { |p| p['id'] }.to_set
80
82
  orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
81
83
 
82
- headers = %w[SLUG TITLE CATEGORY SURFACES STATUS]
84
+ headers = %w[SLUG TITLE CATEGORY TAGS SURFACES STATUS]
83
85
  rows = []
84
86
 
85
87
  parents.each do |r|
86
88
  surfaces = (r['required_surfaces'] || []).join(', ')
87
89
  category = r['category'] || '-'
88
- rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, status_cell(r)]
90
+ tags = (r['tags'] || []).join(', ')
91
+ rows << [r['slug'], truncate(r['title'], 40), category, tags.empty? ? '-' : tags, surfaces.empty? ? '-' : surfaces, status_cell(r)]
89
92
 
90
93
  # Add children indented
91
94
  (children_by_parent[r['id']] || []).each do |child|
92
95
  child_surfaces = (child['required_surfaces'] || []).join(', ')
93
96
  child_category = child['category'] || '-'
94
- rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_surfaces.empty? ? '-' : child_surfaces, status_cell(child)]
97
+ child_tags = (child['tags'] || []).join(', ')
98
+ rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_tags.empty? ? '-' : child_tags, child_surfaces.empty? ? '-' : child_surfaces, status_cell(child)]
95
99
  end
96
100
  end
97
101
 
@@ -99,7 +103,8 @@ module Fp
99
103
  orphans.each do |r|
100
104
  surfaces = (r['required_surfaces'] || []).join(', ')
101
105
  category = r['category'] || '-'
102
- rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, status_cell(r)]
106
+ tags = (r['tags'] || []).join(', ')
107
+ rows << [r['slug'], truncate(r['title'], 40), category, tags.empty? ? '-' : tags, surfaces.empty? ? '-' : surfaces, status_cell(r)]
103
108
  end
104
109
 
105
110
  output.table(headers, rows)
@@ -16,7 +16,8 @@ module Fp
16
16
  category: :string,
17
17
  source_type: :string,
18
18
  source_url: :string,
19
- source_context: :string
19
+ source_context: :string,
20
+ tags: :string
20
21
  }.freeze
21
22
 
22
23
  VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
@@ -62,6 +63,12 @@ module Fp
62
63
  params[:source_url] = opts[:source_url] if opts[:source_url]
63
64
  params[:source_context] = opts[:source_context] if opts[:source_context]
64
65
 
66
+ # Tags are comma-separated. Send the raw list — the API normalizes
67
+ # (downcase/trim/dedupe) and validates.
68
+ if opts[:tags]
69
+ params[:tags] = opts[:tags].split(',').map(&:strip).reject(&:empty?)
70
+ end
71
+
65
72
  # Resolve parent slug to parent_id if provided
66
73
  if opts[:parent]
67
74
  parent_id = resolve_parent_id(project_id, opts[:parent])
@@ -85,6 +92,7 @@ module Fp
85
92
  puts " Title: #{requirement['title']}"
86
93
  puts " Status: #{requirement['status']}"
87
94
  puts " Category: #{requirement['category'] || '(none)'}"
95
+ puts " Tags: #{(requirement['tags'] || []).join(', ')}" unless (requirement['tags'] || []).empty?
88
96
  puts " Surfaces: #{(requirement['required_surfaces'] || []).join(', ')}"
89
97
  puts " Parent: #{opts[:parent] || '(none)'}" if opts[:parent]
90
98
  puts
@@ -116,6 +116,9 @@ module Fp
116
116
  if opts[:pr]
117
117
  puts " PR: #{opts[:pr]}"
118
118
  end
119
+ if opts[:sha]
120
+ puts " SHA: #{opts[:sha]}"
121
+ end
119
122
  if opts[:work_item]
120
123
  puts " Work: #{opts[:work_item]}"
121
124
  end
@@ -67,6 +67,8 @@ module Fp
67
67
  puts "Slug: #{req['slug']}"
68
68
  puts "Status: #{req['status']}"
69
69
  puts "Category: #{req['category'] || '(none)'}"
70
+ tags = req['tags'] || []
71
+ puts "Tags: #{tags.empty? ? '(none)' : tags.join(', ')}"
70
72
  puts "Parent: #{parent_slug || req['parent_id'] || '(none)'}"
71
73
  puts
72
74
 
@@ -110,6 +112,7 @@ module Fp
110
112
  puts " File: #{loc}"
111
113
  end
112
114
  puts " Repo: #{e['repo']}" if e['repo']
115
+ puts " Commit: #{e['commit_sha']}" if e['commit_sha']
113
116
  puts " PR: #{e['pr_url']}" if e['pr_url']
114
117
  puts " Work: #{e['work_item_url']}" if e['work_item_url']
115
118
  puts " CI: #{e['ci_url']}" if e['ci_url']
data/lib/fp/parity.rb CHANGED
@@ -17,10 +17,11 @@ module Fp
17
17
  # here rather than changing any verdict.
18
18
  module Parity
19
19
  # Worst → best, matching the server's severity order. Used for stable output.
20
- STATES = %w[failing missing suspect stub present passing].freeze
20
+ STATES = %w[failing missing suspect stub present pr_passing passing].freeze
21
21
 
22
22
  GLYPHS = {
23
23
  'passing' => '✓',
24
+ 'pr_passing' => '◐',
24
25
  'failing' => '✗',
25
26
  'present' => '●',
26
27
  'stub' => '○',
@@ -42,7 +43,7 @@ module Fp
42
43
 
43
44
  LEVELS = %w[off regression covered strict].freeze
44
45
 
45
- LEGEND = 'Legend: ✓ passing ✗ failing ● present ○ stub ⚠ suspect · missing - not required'
46
+ LEGEND = 'Legend: ✓ passing ◐ pr-passing ✗ failing ● present ○ stub ⚠ suspect · missing - not required'
46
47
 
47
48
  # Appended to the legend only when the grid actually contains one, so the common
48
49
  # case isn't cluttered by vocabulary nothing on screen uses.
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.7'
4
+ VERSION = '0.0.8'
5
5
  end
data/skills/fp/SKILL.md CHANGED
@@ -112,11 +112,24 @@ fp report --junit junit.xml \
112
112
  fp propose --project stowzilla \
113
113
  --slug print_container_qr \
114
114
  --name "Print QR on container label" \
115
- --why "Enables scanning containers in the warehouse" \
115
+ --why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \
116
116
  --required api,customer_android \
117
- --acceptance "Label shows scannable QR code"
117
+ --acceptance "QR scans to the container's detail page"
118
118
  ```
119
119
 
120
+ **Writing `--why`: brief, and it actually explains why.** Whoever approves this
121
+ requirement should grasp in one read why the product needs it — the user problem it
122
+ solves or the outcome it protects. State that and stop. Longer is fine when it earns
123
+ its length; wordy is not. Avoid two things: filler that says nothing specific, and
124
+ describing *how* the feature works instead of *why* anyone wants it.
125
+
126
+ - ✅ `--why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."`
127
+ - ❌ `--why "Ensures a high-quality experience across all surfaces."` (filler — says nothing specific)
128
+ - ❌ `--why "The label renders a QR image encoding the container ID at print time."` (how it works, not why anyone needs it)
129
+
130
+ Same rule for `--acceptance`: the observable condition that proves it's done, not a
131
+ restatement of the title.
132
+
120
133
  **Agents always create requirements as draft.** A human must activate in the web app.
121
134
 
122
135
  ### 6. Propose a change to an existing requirement
@@ -136,9 +136,9 @@ Propose a new requirement as draft.
136
136
  fp propose --project stowzilla \
137
137
  --slug print_container_qr \
138
138
  --name "Print QR on container label" \
139
- --why "Enables scanning containers" \
139
+ --why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it" \
140
140
  --required api,customer_android \
141
- --acceptance "Label shows scannable QR code"
141
+ --acceptance "QR scans to the container's detail page"
142
142
  ```
143
143
 
144
144
  | Flag | Required | Description |
@@ -146,9 +146,18 @@ fp propose --project stowzilla \
146
146
  | `--project` | Yes | Project slug |
147
147
  | `--slug` | Yes | Requirement slug (immutable, choose carefully) |
148
148
  | `--name` | Yes | Human-readable name |
149
- | `--why` | No | Why this requirement matters |
149
+ | `--why` | No | One plain sentence: the user need or outcome the feature serves (see below) |
150
150
  | `--required` | No | Comma-separated surfaces that must implement this |
151
- | `--acceptance` | No | How to verify completion |
151
+ | `--acceptance` | No | The observable condition that proves it's done |
152
+
153
+ **Writing `--why`:** brief, and it actually explains why. Whoever approves this should
154
+ grasp in one read the user need or outcome the feature serves. Longer is fine when it
155
+ earns its length; wordy is not. Avoid filler, and avoid describing *how* it works
156
+ instead of *why* anyone wants it.
157
+
158
+ - ✅ `"Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."`
159
+ - ❌ `"Ensures a high-quality experience across all surfaces."` (filler)
160
+ - ❌ `"The label renders a QR image encoding the container ID at print time."` (how it works, not why)
152
161
 
153
162
  **Agents always create requirements as draft.** A human must activate in the web app.
154
163
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.7
4
+ version: 0.0.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla