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.
@@ -0,0 +1,384 @@
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
+ ### 2. If the requirement is wrong, propose a change — don't silently edit it
313
+
314
+ Shipping something that contradicts a requirement means one of the two is wrong.
315
+ Say which, in the record:
316
+
317
+ ```bash
318
+ fp propose-change <slug> --why "..." --acceptance "..." \\
319
+ --reason "what prompted this" \\
320
+ --source-type fizzy --source-url <card-or-issue-url>
321
+
322
+ fp propose-change <slug> --removal --reason "superseded by <other-slug>"
323
+ ```
324
+
325
+ What happens: the change is applied, the requirement drops back to **draft**, and a
326
+ human reviews the old-vs-new diff. Rejecting restores the previous wording verbatim.
327
+ What it *was* is kept, so nothing is lost either way.
328
+
329
+ Rules worth knowing before you try:
330
+
331
+ - Never edit a requirement's wording by any other route. `fp propose-change` is the
332
+ only one that records what it was and what prompted the change.
333
+ - You cannot change `--slug` (it's the join to every `fp:<slug>` marker) or remove a
334
+ required surface. Both come back as 403.
335
+ - You cannot approve or reject — not even your own proposal.
336
+ - One open proposal per requirement (409 otherwise). Run `fp history <slug>` first.
337
+ - **A requirement awaiting review still counts toward the gate.** Proposing a change
338
+ is not a way to make a red gate green; it only marks the existing evidence
339
+ `suspect`, because the tests were written against wording that has moved.
340
+
341
+ ### 3. Mark the test
342
+
343
+ Add an `fp:<slug>` comment directly above the test. Keep the test's own name
344
+ readable — the marker is the join, not the name.
345
+
346
+ ```ruby
347
+ # fp:print_container_qr
348
+ it 'prints a QR code onto the container label' do
349
+ ```
350
+
351
+ Pin surfaces in the marker when one test satisfies more than one:
352
+ `# fp:print_container_qr@api,web`.
353
+
354
+ Every feature needs one. A requirement with no marked test is a gap, and gaps are
355
+ what the gate is eventually going to fail on.
356
+
357
+ ### 4. Report the evidence
358
+
359
+ ```bash
360
+ fp report --junit junit.xml --surface <surface> --repo <org/repo> \\
361
+ --work-item <card-or-issue-url>
362
+ ```
363
+
364
+ Report `present` or `stub` only — **never** claim `passing`/`failing`. Those are CI's
365
+ to write, via `fp ci-report`, and the gate trusts them precisely because agents can't
366
+ set them.
367
+
368
+ If you just changed a requirement's wording and re-wrote its tests, re-report the
369
+ evidence: that's what clears `suspect`.
370
+
371
+ ### 5. Confirm you didn't break the gate
372
+
373
+ ```bash
374
+ fp check # exits non-zero if this project now violates its level
375
+ ```
376
+
377
+ `fp check --json` if you want to parse the violations. Run `fp help` for the full
378
+ command list.
379
+ #{AGENTS_END_MARKER}
380
+ MARKDOWN
381
+ end
382
+ end
383
+ end
384
+ end
@@ -15,9 +15,13 @@ module Fp
15
15
 
16
16
  def run(args)
17
17
  opts, = parse_flags(args, KNOWN_FLAGS)
18
- require_flag(opts, :project)
18
+ project_config = ProjectConfig.discover
19
+ project_slug = require_project!(opts, project_config)
20
+ project_id = resolve_project_id(project_slug)
19
21
 
20
- project_id = resolve_project_id(opts[:project])
22
+ # A single-surface repo can declare its surface once in .featureparity.yml
23
+ # rather than passing --surface on every call.
24
+ opts[:surface] ||= project_config.surface
21
25
 
22
26
  if opts[:gaps]
23
27
  list_gaps(project_id, opts)
@@ -37,6 +41,7 @@ module Fp
37
41
  end
38
42
 
39
43
  requirements = result[:data]['requirements'] || []
44
+ all = requirements
40
45
 
41
46
  # Filter by status (default: active only)
42
47
  status_filter = opts[:status] || 'active'
@@ -49,7 +54,15 @@ module Fp
49
54
  end
50
55
  end
51
56
 
52
- output.success({ requirements: requirements, project: opts[:project] }) do
57
+ # Requirements a proposed change knocked back to draft. The default `--status
58
+ # active` filter hides them, which would make an active requirement appear to
59
+ # vanish the moment an agent proposed a change to it — so they get named below
60
+ # even when the filter excluded them. They still count toward the gate.
61
+ awaiting = all.select { |r| r['pending_change_kind'].to_s != '' }
62
+ hidden_awaiting = awaiting.reject { |r| requirements.include?(r) }
63
+
64
+ output.success({ requirements: requirements, project: opts[:project],
65
+ awaiting_review: awaiting }) do
53
66
  if requirements.empty?
54
67
  filter_desc = [status_filter]
55
68
  filter_desc << "category: #{opts[:category]}" if opts[:category]
@@ -72,13 +85,13 @@ module Fp
72
85
  parents.each do |r|
73
86
  surfaces = (r['required_surfaces'] || []).join(', ')
74
87
  category = r['category'] || '-'
75
- rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
88
+ rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, status_cell(r)]
76
89
 
77
90
  # Add children indented
78
91
  (children_by_parent[r['id']] || []).each do |child|
79
92
  child_surfaces = (child['required_surfaces'] || []).join(', ')
80
93
  child_category = child['category'] || '-'
81
- rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_surfaces.empty? ? '-' : child_surfaces, child['status']]
94
+ rows << [" └ #{child['slug']}", truncate(child['title'], 36), child_category, child_surfaces.empty? ? '-' : child_surfaces, status_cell(child)]
82
95
  end
83
96
  end
84
97
 
@@ -86,56 +99,123 @@ module Fp
86
99
  orphans.each do |r|
87
100
  surfaces = (r['required_surfaces'] || []).join(', ')
88
101
  category = r['category'] || '-'
89
- rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, r['status']]
102
+ rows << [r['slug'], truncate(r['title'], 40), category, surfaces.empty? ? '-' : surfaces, status_cell(r)]
90
103
  end
91
104
 
92
105
  output.table(headers, rows)
93
106
  end
107
+
108
+ print_awaiting_review(hidden_awaiting)
94
109
  end
95
110
  end
96
111
 
112
+ # "draft" alone doesn't distinguish "never approved" from "was approved, someone
113
+ # proposed a change". The pointer on the requirement does, so use it.
114
+ def status_cell(requirement)
115
+ kind = requirement['pending_change_kind'].to_s
116
+ return requirement['status'] if kind.empty?
117
+
118
+ "#{requirement['status']} (#{kind} proposed)"
119
+ end
120
+
121
+ def print_awaiting_review(hidden)
122
+ return if hidden.empty?
123
+
124
+ puts
125
+ puts "#{hidden.size} requirement(s) not listed above are awaiting review of a proposed change:"
126
+ hidden.each { |r| puts " #{r['slug']} — #{r['pending_change_kind']} proposed (fp history #{r['slug']})" }
127
+ puts 'They are still enforced by the gate. A human must approve or reject the change.'
128
+ end
129
+
130
+ # Gaps: active requirements with at least one required surface that has no real
131
+ # evidence behind it.
132
+ #
133
+ # This used to return *every* active requirement, because evidence was never
134
+ # consulted — "gaps" and "list" produced the same rows, so `--gaps` told you
135
+ # nothing. It now reads the parity report at the `covered` level, where a cell
136
+ # violates when it is missing, stubbed, or failing. That's the level whose
137
+ # definition of a violation matches what "gap" has always meant.
97
138
  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])
139
+ result = client.get_parity(project_id, level: 'covered', surface: opts[:surface])
101
140
 
102
141
  unless result[:ok]
103
142
  output.error(result[:error], status: result[:status])
104
143
  exit 1
105
144
  end
106
145
 
107
- # Only show active requirements for gaps
108
- requirements = (result[:data]['requirements'] || []).select { |r| r['status'] == 'active' }
146
+ report = result[:data]['parity'] || {}
147
+
148
+ # Group the violating cells back up per requirement, so a requirement missing
149
+ # three surfaces is one row naming three surfaces rather than three rows.
150
+ gaps_by_slug = {}
151
+ (report['violations'] || []).each do |violation|
152
+ entry = gaps_by_slug[violation['slug']] ||= {
153
+ 'slug' => violation['slug'],
154
+ 'title' => violation['title'],
155
+ 'surfaces' => []
156
+ }
157
+ entry['surfaces'] << { 'surface' => violation['surface'], 'state' => violation['state'] }
158
+ end
109
159
 
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
160
+ # Category isn't on the parity report, so resolve it from the requirements the
161
+ # report carries — and apply --category here rather than server-side.
162
+ rows_by_slug = (report['requirements'] || []).to_h { |r| [r['slug'], r] }
163
+ gaps = gaps_by_slug.values
164
+ gaps.each do |gap|
165
+ row = rows_by_slug[gap['slug']] || {}
166
+ gap['category'] = row['category']
167
+ # A gap on a requirement whose wording is mid-change is a different problem
168
+ # from a plain uncovered one — the fix may be to review the change, not to
169
+ # write a test.
170
+ gap['under_review'] = !!row['under_review']
115
171
  end
172
+ gaps = gaps.select { |gap| gap['category'] == opts[:category] } if opts[:category]
116
173
 
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?
174
+ render_gaps(gaps, report, opts)
175
+ end
176
+
177
+ def render_gaps(gaps, report, opts)
178
+ payload = {
179
+ gaps: gaps,
180
+ project: opts[:project],
181
+ surface: opts[:surface],
182
+ category: opts[:category],
183
+ summary: report['summary']
184
+ }
185
+
186
+ output.success(payload) do
187
+ if gaps.empty?
121
188
  filter_desc = []
122
189
  filter_desc << "category: #{opts[:category]}" if opts[:category]
123
190
  filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
124
191
  msg = 'No gaps found'
125
192
  msg += " (#{filter_desc.join(', ')})" unless filter_desc.empty?
126
193
  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]
194
+
195
+ unscoped = (report['summary'] || {})['unscoped'].to_i
196
+ if unscoped.positive?
197
+ puts "Note: #{unscoped} requirement(s) name no surface this project has, " \
198
+ 'so nothing can cover them.'
137
199
  end
138
- output.table(headers, rows)
200
+ next
201
+ end
202
+
203
+ desc = "Gaps for project '#{opts[:project]}'"
204
+ desc += " (surface: #{opts[:surface]})" if opts[:surface]
205
+ desc += " (category: #{opts[:category]})" if opts[:category]
206
+ puts "#{desc}:"
207
+
208
+ headers = %w[SLUG TITLE CATEGORY UNCOVERED]
209
+ rows = gaps.map do |gap|
210
+ uncovered = gap['surfaces'].map { |s| "#{s['surface']} (#{s['state']})" }.join(', ')
211
+ slug = gap['under_review'] ? "#{gap['slug']} #{Parity::UNDER_REVIEW}" : gap['slug']
212
+ [slug, truncate(gap['title'], 40), gap['category'] || '-', uncovered]
213
+ end
214
+ output.table(headers, rows)
215
+
216
+ if gaps.any? { |gap| gap['under_review'] }
217
+ puts
218
+ 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
219
  end
140
220
  end
141
221
  end