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.
@@ -0,0 +1,397 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module Fp
6
+ module Commands
7
+ # fp init --project <slug> [--surface api] [--level regression] [--force]
8
+ # [--no-workflow] [--no-agents] [--dir PATH]
9
+ #
10
+ # Onboards a repo onto FeatureParity by writing the three things enforcement needs
11
+ # to actually happen, rather than being something a human remembers to do:
12
+ #
13
+ # .featureparity.yml which project this code is
14
+ # .github/workflows/feature-parity.yml the CI gate that blocks merges
15
+ # AGENTS.md (appended section) the instructions agents read
16
+ #
17
+ # The last one is the point. A gate that fails is only useful if the agent that
18
+ # opened the PR knows why and what to do about it — otherwise enforcement is just a
19
+ # red X nobody can action. Writing the marker/report/propose workflow into the file
20
+ # agents already read on every task is what closes that loop.
21
+ #
22
+ # Everything written is idempotent and additive: existing files are left alone
23
+ # unless --force, and the AGENTS.md section is appended once and detected by a
24
+ # marker comment on re-run.
25
+ class Init < Base
26
+ KNOWN_FLAGS = {
27
+ project: :string,
28
+ surface: :string,
29
+ level: :string,
30
+ dir: :string,
31
+ force: :boolean,
32
+ no_workflow: :boolean,
33
+ no_agents: :boolean
34
+ }.freeze
35
+
36
+ WORKFLOW_PATH = File.join('.github', 'workflows', 'feature-parity.yml')
37
+
38
+ # Files an agent is likely to already be reading. The FP section is appended to
39
+ # whichever of these exist; if none do, AGENTS.md is created.
40
+ AGENT_DOC_CANDIDATES = %w[AGENTS.md CLAUDE.md].freeze
41
+
42
+ # Lets a re-run recognise its own previous output instead of appending twice.
43
+ AGENTS_MARKER = '<!-- feature-parity:begin -->'
44
+ AGENTS_END_MARKER = '<!-- feature-parity:end -->'
45
+
46
+ def run(args)
47
+ opts, = parse_flags(args, KNOWN_FLAGS)
48
+
49
+ root = File.expand_path(opts[:dir] || Dir.pwd)
50
+ unless File.directory?(root)
51
+ output.error("Not a directory: #{root}")
52
+ exit 1
53
+ end
54
+
55
+ existing = ProjectConfig.discover(root)
56
+ project_slug = opts[:project] || existing.project
57
+ unless project_slug
58
+ output.error('--project is required (the FeatureParity project slug this repo belongs to)')
59
+ exit 1
60
+ end
61
+
62
+ level = opts[:level]
63
+ if level && !Parity::LEVELS.include?(level)
64
+ output.error("--level must be one of: #{Parity::LEVELS.join(', ')}")
65
+ exit 1
66
+ end
67
+
68
+ written = []
69
+ skipped = []
70
+
71
+ write_project_config(root, project_slug, level, opts, written, skipped)
72
+ write_workflow(root, opts, written, skipped) unless opts[:no_workflow]
73
+ write_agent_docs(root, project_slug, opts, written, skipped) unless opts[:no_agents]
74
+
75
+ report(project_slug, root, written, skipped, opts)
76
+ end
77
+
78
+ private
79
+
80
+ def write_project_config(root, project_slug, level, opts, written, skipped)
81
+ path = File.join(root, ProjectConfig::FILENAME)
82
+ body = ProjectConfig.render(
83
+ project: project_slug,
84
+ enforcement: level,
85
+ surface: opts[:surface]
86
+ )
87
+
88
+ write_file(path, body, opts, written, skipped)
89
+ end
90
+
91
+ def write_workflow(root, opts, written, skipped)
92
+ path = File.join(root, WORKFLOW_PATH)
93
+ write_file(path, workflow_body, opts, written, skipped)
94
+ end
95
+
96
+ # Appends the FP section to the agent-facing docs, creating AGENTS.md if none of
97
+ # the candidates exist. Appending rather than overwriting matters: these files are
98
+ # hand-maintained and full of project-specific instruction we must not clobber.
99
+ def write_agent_docs(root, project_slug, opts, written, skipped)
100
+ targets = AGENT_DOC_CANDIDATES.select { |name| File.file?(File.join(root, name)) }
101
+ targets = [AGENT_DOC_CANDIDATES.first] if targets.empty?
102
+
103
+ section = agents_section(project_slug)
104
+
105
+ targets.each do |name|
106
+ path = File.join(root, name)
107
+
108
+ if File.file?(path)
109
+ content = File.read(path)
110
+ if content.include?(AGENTS_MARKER) && !opts[:force]
111
+ skipped << [relative(root, path), 'already has a FeatureParity section']
112
+ next
113
+ end
114
+
115
+ content = strip_existing_section(content) if content.include?(AGENTS_MARKER)
116
+ separator = content.end_with?("\n") ? "\n" : "\n\n"
117
+ File.write(path, "#{content}#{separator}#{section}")
118
+ written << [relative(root, path), 'appended FeatureParity section']
119
+ else
120
+ File.write(path, section)
121
+ written << [relative(root, path), 'created']
122
+ end
123
+ end
124
+ end
125
+
126
+ # Remove a previously written section so --force refreshes rather than duplicates.
127
+ def strip_existing_section(content)
128
+ content.sub(/\n*#{Regexp.escape(AGENTS_MARKER)}.*?#{Regexp.escape(AGENTS_END_MARKER)}\n?/m, "\n")
129
+ end
130
+
131
+ def write_file(path, body, opts, written, skipped)
132
+ root = File.expand_path(opts[:dir] || Dir.pwd)
133
+ relative_path = relative(root, path)
134
+ existed = File.exist?(path)
135
+
136
+ if existed && !opts[:force]
137
+ skipped << [relative_path, 'already exists (use --force to overwrite)']
138
+ return
139
+ end
140
+
141
+ FileUtils.mkdir_p(File.dirname(path))
142
+ File.write(path, body)
143
+ written << [relative_path, existed ? 'overwritten' : 'created']
144
+ end
145
+
146
+ def relative(root, path)
147
+ path.sub(%r{\A#{Regexp.escape(root)}/?}, '')
148
+ end
149
+
150
+ def report(project_slug, root, written, skipped, opts)
151
+ output.success({
152
+ project: project_slug,
153
+ dir: root,
154
+ written: written.map { |p, note| { path: p, note: note } },
155
+ skipped: skipped.map { |p, note| { path: p, note: note } }
156
+ }) do
157
+ puts "Initialized FeatureParity for project '#{project_slug}' in #{root}"
158
+ puts
159
+
160
+ unless written.empty?
161
+ puts 'Wrote:'
162
+ written.each { |p, note| puts " #{p} (#{note})" }
163
+ puts
164
+ end
165
+
166
+ unless skipped.empty?
167
+ puts 'Skipped:'
168
+ skipped.each { |p, note| puts " #{p} — #{note}" }
169
+ puts
170
+ end
171
+
172
+ puts 'Next steps:'
173
+ puts " 1. Make sure the surfaces this repo ships exist: fp surfaces --project #{project_slug}"
174
+ puts ' 2. Add the FP_API_KEY secret to the repo so CI can report evidence and run the gate.'
175
+ puts " 3. Try the gate locally: fp check#{opts[:surface] ? " --surface #{opts[:surface]}" : ''}"
176
+ puts
177
+ puts 'The gate starts at the `regression` level, which only fails on tests that have'
178
+ puts 'gone red — it will not block you on requirements that were never covered. Raise'
179
+ puts 'the level to `covered` or `strict` once the gaps are paid down.'
180
+ end
181
+ end
182
+
183
+ # The CI gate. Two jobs on purpose: reporting evidence and enforcing the gate are
184
+ # separate concerns, and the gate must run on pull requests (where it blocks a
185
+ # merge) while evidence reporting only makes sense on trusted pushes.
186
+ def workflow_body
187
+ <<~YAML
188
+ # FeatureParity — parity enforcement.
189
+ #
190
+ # Generated by `fp init`. Two jobs, deliberately separate:
191
+ #
192
+ # report On pushes to the default branch, feed test results back to
193
+ # FeatureParity so the parity matrix reflects real pass/fail.
194
+ # gate On pull requests, fail the build when the project violates its
195
+ # enforcement level. Make this a required status check in branch
196
+ # protection to actually block merges.
197
+ #
198
+ # The gate reads the project slug from .featureparity.yml, so it needs no
199
+ # hardcoded project name here.
200
+ name: Feature Parity
201
+
202
+ on:
203
+ push:
204
+ branches: [main, master]
205
+ pull_request:
206
+ workflow_dispatch:
207
+
208
+ concurrency:
209
+ group: feature-parity-${{ github.ref }}
210
+ cancel-in-progress: true
211
+
212
+ jobs:
213
+ gate:
214
+ name: Parity gate
215
+ runs-on: ubuntu-latest
216
+ steps:
217
+ - uses: actions/checkout@v4
218
+
219
+ - uses: ruby/setup-ruby@v1
220
+ with:
221
+ ruby-version: '3.3'
222
+
223
+ - name: Install fp
224
+ run: gem install featureparity
225
+
226
+ # Exits non-zero when the project violates its enforcement level.
227
+ # Add `--warn-only` while adopting if you need the annotations without
228
+ # blocking merges yet.
229
+ #
230
+ # NOTE: as written this gates on what has been *recorded*, which on a pull
231
+ # request is the default branch's state. To judge a branch on its own
232
+ # results, emit JUnit in this job and pass it — nothing is saved:
233
+ #
234
+ # fp check --surface <surface-key> --junit junit.xml
235
+ #
236
+ # That is also the only way to catch a test that was deleted, renamed, or
237
+ # had its fp: marker stripped: the remaining tests still pass, so a plain
238
+ # suite stays green while the requirement loses its only proof. Use one
239
+ # call per surface.
240
+ - name: Check parity
241
+ env:
242
+ FP_API_KEY: ${{ secrets.FP_API_KEY }}
243
+ run: fp check
244
+
245
+ # Uncomment and adapt once your suite emits JUnit XML. `fp ci-report` writes
246
+ # real passing/failing evidence — without it, the matrix only ever shows the
247
+ # `present` markers agents filed, and the gate has nothing green to see.
248
+ #
249
+ # report:
250
+ # name: Report evidence
251
+ # if: github.event_name == 'push'
252
+ # runs-on: ubuntu-latest
253
+ # steps:
254
+ # - uses: actions/checkout@v4
255
+ # - name: Run tests (emit JUnit)
256
+ # run: echo "replace with your test command emitting junit.xml"
257
+ # - name: Report to FeatureParity
258
+ # if: always()
259
+ # env:
260
+ # FP_API_KEY: ${{ secrets.FP_API_KEY }}
261
+ # run: |
262
+ # gem install featureparity
263
+ # fp ci-report --junit junit.xml \\
264
+ # --surface <surface-key> \\
265
+ # --ci-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
266
+ YAML
267
+ end
268
+
269
+ # The agent-facing brief. Written as instructions rather than description, because
270
+ # this text is read by something that is about to write a test and needs to know
271
+ # exactly what to do.
272
+ def agents_section(project_slug)
273
+ <<~MARKDOWN
274
+ #{AGENTS_MARKER}
275
+ ## FeatureParity (this project tracks feature parity)
276
+
277
+ This repo is tracked by [FeatureParity](https://featureparity.dev) as project
278
+ **`#{project_slug}`** (see `.featureparity.yml`). Requirements are shared across every
279
+ surface the product ships on, and CI enforces that they stay covered — a pull request
280
+ that lets a covered requirement go red will fail the `Parity gate` check.
281
+
282
+ Two things are expected of you on every change, not just when someone asks:
283
+
284
+ 1. **A feature or fix ships with a test bound to its requirement** (`fp:<slug>`).
285
+ 2. **The requirements stay true.** If none covers what you're building, propose one.
286
+ If one is wrong or outdated, propose the change — never edit or ignore it.
287
+
288
+ **Start every task by checking the requirements — before you write code, not
289
+ after.** When you pick up a feature or bug, first run `fp list` / `fp show` to find
290
+ the requirement it belongs to. If one exists, you'll bind your test to it; if none
291
+ does, propose one (as a draft) so the work is on the record from the outset. Treating
292
+ this as the opening move — not a chore at the end — is what keeps parity honest.
293
+
294
+ ### 1. Find the requirement — do this first
295
+
296
+ ```bash
297
+ fp list # active requirements (project comes from .featureparity.yml)
298
+ fp list --gaps # the ones with no evidence yet — good places to start
299
+ fp show <slug> # details, acceptance criteria, and existing receipts
300
+ fp history <slug> # why it says what it says, and whether a change is pending
301
+ ```
302
+
303
+ If no requirement covers what you're building, propose one. Agents always create
304
+ **drafts**; a human activates them:
305
+
306
+ ```bash
307
+ fp propose --slug <slug> --name "..." --required <surface>,<surface> \\
308
+ --why "..." --acceptance "..." \\
309
+ --source-type fizzy --source-url <card-or-issue-url>
310
+ ```
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
+
325
+ ### 2. If the requirement is wrong, propose a change — don't silently edit it
326
+
327
+ Shipping something that contradicts a requirement means one of the two is wrong.
328
+ Say which, in the record:
329
+
330
+ ```bash
331
+ fp propose-change <slug> --why "..." --acceptance "..." \\
332
+ --reason "what prompted this" \\
333
+ --source-type fizzy --source-url <card-or-issue-url>
334
+
335
+ fp propose-change <slug> --removal --reason "superseded by <other-slug>"
336
+ ```
337
+
338
+ What happens: the change is applied, the requirement drops back to **draft**, and a
339
+ human reviews the old-vs-new diff. Rejecting restores the previous wording verbatim.
340
+ What it *was* is kept, so nothing is lost either way.
341
+
342
+ Rules worth knowing before you try:
343
+
344
+ - Never edit a requirement's wording by any other route. `fp propose-change` is the
345
+ only one that records what it was and what prompted the change.
346
+ - You cannot change `--slug` (it's the join to every `fp:<slug>` marker) or remove a
347
+ required surface. Both come back as 403.
348
+ - You cannot approve or reject — not even your own proposal.
349
+ - One open proposal per requirement (409 otherwise). Run `fp history <slug>` first.
350
+ - **A requirement awaiting review still counts toward the gate.** Proposing a change
351
+ is not a way to make a red gate green; it only marks the existing evidence
352
+ `suspect`, because the tests were written against wording that has moved.
353
+
354
+ ### 3. Mark the test
355
+
356
+ Add an `fp:<slug>` comment directly above the test. Keep the test's own name
357
+ readable — the marker is the join, not the name.
358
+
359
+ ```ruby
360
+ # fp:print_container_qr
361
+ it 'prints a QR code onto the container label' do
362
+ ```
363
+
364
+ Pin surfaces in the marker when one test satisfies more than one:
365
+ `# fp:print_container_qr@api,web`.
366
+
367
+ Every feature needs one. A requirement with no marked test is a gap, and gaps are
368
+ what the gate is eventually going to fail on.
369
+
370
+ ### 4. Report the evidence
371
+
372
+ ```bash
373
+ fp report --junit junit.xml --surface <surface> --repo <org/repo> \\
374
+ --work-item <card-or-issue-url>
375
+ ```
376
+
377
+ Report `present` or `stub` only — **never** claim `passing`/`failing`. Those are CI's
378
+ to write, via `fp ci-report`, and the gate trusts them precisely because agents can't
379
+ set them.
380
+
381
+ If you just changed a requirement's wording and re-wrote its tests, re-report the
382
+ evidence: that's what clears `suspect`.
383
+
384
+ ### 5. Confirm you didn't break the gate
385
+
386
+ ```bash
387
+ fp check # exits non-zero if this project now violates its level
388
+ ```
389
+
390
+ `fp check --json` if you want to parse the violations. Run `fp help` for the full
391
+ command list.
392
+ #{AGENTS_END_MARKER}
393
+ MARKDOWN
394
+ end
395
+ end
396
+ end
397
+ end
@@ -9,15 +9,20 @@ 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
15
16
 
16
17
  def run(args)
17
18
  opts, = parse_flags(args, KNOWN_FLAGS)
18
- require_flag(opts, :project)
19
+ project_config = ProjectConfig.discover
20
+ project_slug = require_project!(opts, project_config)
21
+ project_id = resolve_project_id(project_slug)
19
22
 
20
- project_id = resolve_project_id(opts[:project])
23
+ # A single-surface repo can declare its surface once in .featureparity.yml
24
+ # rather than passing --surface on every call.
25
+ opts[:surface] ||= project_config.surface
21
26
 
22
27
  if opts[:gaps]
23
28
  list_gaps(project_id, opts)
@@ -29,7 +34,7 @@ module Fp
29
34
  private
30
35
 
31
36
  def list_requirements(project_id, opts)
32
- 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])
33
38
 
34
39
  unless result[:ok]
35
40
  output.error(result[:error], status: result[:status])
@@ -37,6 +42,7 @@ module Fp
37
42
  end
38
43
 
39
44
  requirements = result[:data]['requirements'] || []
45
+ all = requirements
40
46
 
41
47
  # Filter by status (default: active only)
42
48
  status_filter = opts[:status] || 'active'
@@ -49,10 +55,19 @@ module Fp
49
55
  end
50
56
  end
51
57
 
52
- output.success({ requirements: requirements, project: opts[:project] }) do
58
+ # Requirements a proposed change knocked back to draft. The default `--status
59
+ # active` filter hides them, which would make an active requirement appear to
60
+ # vanish the moment an agent proposed a change to it — so they get named below
61
+ # even when the filter excluded them. They still count toward the gate.
62
+ awaiting = all.select { |r| r['pending_change_kind'].to_s != '' }
63
+ hidden_awaiting = awaiting.reject { |r| requirements.include?(r) }
64
+
65
+ output.success({ requirements: requirements, project: opts[:project],
66
+ awaiting_review: awaiting }) do
53
67
  if requirements.empty?
54
68
  filter_desc = [status_filter]
55
69
  filter_desc << "category: #{opts[:category]}" if opts[:category]
70
+ filter_desc << "tag: #{opts[:tag]}" if opts[:tag]
56
71
  filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
57
72
  puts "No requirements found (#{filter_desc.join(', ')})."
58
73
  else
@@ -66,19 +81,21 @@ module Fp
66
81
  parent_ids = parents.map { |p| p['id'] }.to_set
67
82
  orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
68
83
 
69
- headers = %w[SLUG TITLE CATEGORY SURFACES STATUS]
84
+ headers = %w[SLUG TITLE CATEGORY TAGS SURFACES STATUS]
70
85
  rows = []
71
86
 
72
87
  parents.each do |r|
73
88
  surfaces = (r['required_surfaces'] || []).join(', ')
74
89
  category = r['category'] || '-'
75
- rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
90
+ tags = (r['tags'] || []).join(', ')
91
+ rows << [r['slug'], truncate(r['title'], 40), category, tags.empty? ? '-' : tags, surfaces.empty? ? '-' : surfaces, status_cell(r)]
76
92
 
77
93
  # Add children indented
78
94
  (children_by_parent[r['id']] || []).each do |child|
79
95
  child_surfaces = (child['required_surfaces'] || []).join(', ')
80
96
  child_category = child['category'] || '-'
81
- rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_surfaces.empty? ? '-' : child_surfaces, child['status']]
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)]
82
99
  end
83
100
  end
84
101
 
@@ -86,56 +103,124 @@ module Fp
86
103
  orphans.each do |r|
87
104
  surfaces = (r['required_surfaces'] || []).join(', ')
88
105
  category = r['category'] || '-'
89
- rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
106
+ tags = (r['tags'] || []).join(', ')
107
+ rows << [r['slug'], truncate(r['title'], 40), category, tags.empty? ? '-' : tags, surfaces.empty? ? '-' : surfaces, status_cell(r)]
90
108
  end
91
109
 
92
110
  output.table(headers, rows)
93
111
  end
112
+
113
+ print_awaiting_review(hidden_awaiting)
94
114
  end
95
115
  end
96
116
 
117
+ # "draft" alone doesn't distinguish "never approved" from "was approved, someone
118
+ # proposed a change". The pointer on the requirement does, so use it.
119
+ def status_cell(requirement)
120
+ kind = requirement['pending_change_kind'].to_s
121
+ return requirement['status'] if kind.empty?
122
+
123
+ "#{requirement['status']} (#{kind} proposed)"
124
+ end
125
+
126
+ def print_awaiting_review(hidden)
127
+ return if hidden.empty?
128
+
129
+ puts
130
+ puts "#{hidden.size} requirement(s) not listed above are awaiting review of a proposed change:"
131
+ hidden.each { |r| puts " #{r['slug']} — #{r['pending_change_kind']} proposed (fp history #{r['slug']})" }
132
+ puts 'They are still enforced by the gate. A human must approve or reject the change.'
133
+ end
134
+
135
+ # Gaps: active requirements with at least one required surface that has no real
136
+ # evidence behind it.
137
+ #
138
+ # This used to return *every* active requirement, because evidence was never
139
+ # consulted — "gaps" and "list" produced the same rows, so `--gaps` told you
140
+ # nothing. It now reads the parity report at the `covered` level, where a cell
141
+ # violates when it is missing, stubbed, or failing. That's the level whose
142
+ # definition of a violation matches what "gap" has always meant.
97
143
  def list_gaps(project_id, opts)
98
- # Gaps endpoint may not exist yet - fall back to computing from requirements
99
- # For now, list requirements that don't have evidence for a surface
100
- result = client.list_requirements(project_id: project_id, category: opts[:category])
144
+ result = client.get_parity(project_id, level: 'covered', surface: opts[:surface])
101
145
 
102
146
  unless result[:ok]
103
147
  output.error(result[:error], status: result[:status])
104
148
  exit 1
105
149
  end
106
150
 
107
- # Only show active requirements for gaps
108
- requirements = (result[:data]['requirements'] || []).select { |r| r['status'] == 'active' }
151
+ report = result[:data]['parity'] || {}
152
+
153
+ # Group the violating cells back up per requirement, so a requirement missing
154
+ # three surfaces is one row naming three surfaces rather than three rows.
155
+ gaps_by_slug = {}
156
+ (report['violations'] || []).each do |violation|
157
+ entry = gaps_by_slug[violation['slug']] ||= {
158
+ 'slug' => violation['slug'],
159
+ 'title' => violation['title'],
160
+ 'surfaces' => []
161
+ }
162
+ entry['surfaces'] << { 'surface' => violation['surface'], 'state' => violation['state'] }
163
+ end
109
164
 
110
- # Filter by surface if specified
111
- if opts[:surface]
112
- requirements = requirements.select do |r|
113
- (r['required_surfaces'] || []).include?(opts[:surface])
114
- end
165
+ # Category isn't on the parity report, so resolve it from the requirements the
166
+ # report carries — and apply --category here rather than server-side.
167
+ rows_by_slug = (report['requirements'] || []).to_h { |r| [r['slug'], r] }
168
+ gaps = gaps_by_slug.values
169
+ gaps.each do |gap|
170
+ row = rows_by_slug[gap['slug']] || {}
171
+ gap['category'] = row['category']
172
+ # A gap on a requirement whose wording is mid-change is a different problem
173
+ # from a plain uncovered one — the fix may be to review the change, not to
174
+ # write a test.
175
+ gap['under_review'] = !!row['under_review']
115
176
  end
177
+ gaps = gaps.select { |gap| gap['category'] == opts[:category] } if opts[:category]
116
178
 
117
- # For now, gaps = all requirements (evidence API is #1214)
118
- # Once evidence API exists, filter to only those without evidence
119
- output.success({ gaps: requirements, project: opts[:project], surface: opts[:surface], category: opts[:category] }) do
120
- if requirements.empty?
179
+ render_gaps(gaps, report, opts)
180
+ end
181
+
182
+ def render_gaps(gaps, report, opts)
183
+ payload = {
184
+ gaps: gaps,
185
+ project: opts[:project],
186
+ surface: opts[:surface],
187
+ category: opts[:category],
188
+ summary: report['summary']
189
+ }
190
+
191
+ output.success(payload) do
192
+ if gaps.empty?
121
193
  filter_desc = []
122
194
  filter_desc << "category: #{opts[:category]}" if opts[:category]
123
195
  filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
124
196
  msg = 'No gaps found'
125
197
  msg += " (#{filter_desc.join(', ')})" unless filter_desc.empty?
126
198
  puts "#{msg}."
127
- else
128
- desc = "Gaps for project '#{opts[:project]}'"
129
- desc += " (surface: #{opts[:surface]})" if opts[:surface]
130
- desc += " (category: #{opts[:category]})" if opts[:category]
131
- puts "#{desc}:"
132
- headers = %w[SLUG TITLE CATEGORY SURFACES]
133
- rows = requirements.map do |r|
134
- surfaces = (r['required_surfaces'] || []).join(', ')
135
- category = r['category'] || '-'
136
- [r['slug'], truncate(r['title'], 40), category, surfaces]
199
+
200
+ unscoped = (report['summary'] || {})['unscoped'].to_i
201
+ if unscoped.positive?
202
+ puts "Note: #{unscoped} requirement(s) name no surface this project has, " \
203
+ 'so nothing can cover them.'
137
204
  end
138
- output.table(headers, rows)
205
+ next
206
+ end
207
+
208
+ desc = "Gaps for project '#{opts[:project]}'"
209
+ desc += " (surface: #{opts[:surface]})" if opts[:surface]
210
+ desc += " (category: #{opts[:category]})" if opts[:category]
211
+ puts "#{desc}:"
212
+
213
+ headers = %w[SLUG TITLE CATEGORY UNCOVERED]
214
+ rows = gaps.map do |gap|
215
+ uncovered = gap['surfaces'].map { |s| "#{s['surface']} (#{s['state']})" }.join(', ')
216
+ slug = gap['under_review'] ? "#{gap['slug']} #{Parity::UNDER_REVIEW}" : gap['slug']
217
+ [slug, truncate(gap['title'], 40), gap['category'] || '-', uncovered]
218
+ end
219
+ output.table(headers, rows)
220
+
221
+ if gaps.any? { |gap| gap['under_review'] }
222
+ puts
223
+ puts "#{Parity::UNDER_REVIEW} #{Parity::UNDER_REVIEW_LEGEND} — check `fp history <slug>` before writing a test against wording that may be about to move."
139
224
  end
140
225
  end
141
226
  end