featureparity 0.0.28 → 0.0.30

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.
@@ -24,8 +24,10 @@ module Fp
24
24
  # marker comment on re-run.
25
25
  class Init < Base
26
26
  help_summary 'Onboard a repo: write .featureparity.yml, a CI gate workflow, and agent docs'
27
- help_usage 'fp init --project <slug> [--surface X] [--level L] [--dir PATH] [--force]'
27
+ help_usage 'fp init --project <slug> [--surface X] [--level L] [--dir PATH] [--force]',
28
+ 'fp init --detect [--dir PATH] [--json]'
28
29
  help_flag :project, 'Project slug to record in .featureparity.yml', required: true
30
+ help_flag :detect, 'Suggest a slug, surfaces and test-report commands from the repo; writes nothing'
29
31
  help_flag :surface, 'Default surface for this (single-surface) repo'
30
32
  help_flag :level, 'Enforcement level to record (usually omit and let the project decide)'
31
33
  help_flag :dir, 'Target directory (defaults to the working directory)'
@@ -34,7 +36,9 @@ module Fp
34
36
  help_flag :no_agents, 'Skip appending the AGENTS.md section'
35
37
  help_example 'fp init --project stowzilla --surface api'
36
38
  help_example 'fp init --project stowzilla --no-workflow'
37
- help_note 'Makes no API calls, so a repo can be onboarded before anyone has a key.'
39
+ help_example 'fp init --detect', 'See what fp makes of this repo before choosing a slug and surfaces'
40
+ help_note "Makes no API calls, so a repo can be onboarded before anyone has a key.\n" \
41
+ 'Committing the workflow before the FP_API_KEY secret exists is fine: the gate skips with an explanation.'
38
42
 
39
43
  KNOWN_FLAGS = {
40
44
  project: :string,
@@ -43,7 +47,8 @@ module Fp
43
47
  dir: :string,
44
48
  force: :boolean,
45
49
  no_workflow: :boolean,
46
- no_agents: :boolean
50
+ no_agents: :boolean,
51
+ detect: :boolean
47
52
  }.freeze
48
53
 
49
54
  WORKFLOW_PATH = File.join('.github', 'workflows', 'feature-parity.yml')
@@ -56,6 +61,17 @@ module Fp
56
61
  AGENTS_MARKER = '<!-- feature-parity:begin -->'
57
62
  AGENTS_END_MARKER = '<!-- feature-parity:end -->'
58
63
 
64
+ # The version line under the section's heading, so `fp doctor` (and a reader) can tell
65
+ # which fp wrote it. Kept out of AGENTS_MARKER so sections written before the stamp
66
+ # are still recognised as ours.
67
+ AGENTS_VERSION_RE = /Written by fp (\d+\.\d+\.\d+[\w.-]*)/
68
+
69
+ # The guidance the skill and this section share lives once, in the skill, between
70
+ # `<!-- fp-shared:begin NAME -->` / `<!-- fp-shared:end NAME -->`, and is copied in
71
+ # here (Fizzy #1786). Two hand-kept copies had drifted, and an agent with both the
72
+ # skill and AGENTS.md read the same pages twice.
73
+ SKILL_PATH = File.expand_path('../../../skills/fp/SKILL.md', __dir__)
74
+
59
75
  def run(args)
60
76
  opts, = parse_flags(args, KNOWN_FLAGS)
61
77
 
@@ -65,6 +81,8 @@ module Fp
65
81
  exit 1
66
82
  end
67
83
 
84
+ return print_detection(RepoDetector.new(root).detect) if opts[:detect]
85
+
68
86
  existing = ProjectConfig.discover(root)
69
87
  project_slug = opts[:project] || existing.project
70
88
  unless project_slug
@@ -80,6 +98,7 @@ module Fp
80
98
 
81
99
  written = []
82
100
  skipped = []
101
+ @detection = RepoDetector.new(root).detect
83
102
 
84
103
  write_project_config(root, project_slug, level, opts, written, skipped)
85
104
  write_workflow(root, opts, written, skipped) unless opts[:no_workflow]
@@ -97,8 +116,38 @@ module Fp
97
116
  written.map(&:first)
98
117
  end
99
118
 
119
+ # One shared block from the skill, markers included. A gem missing its skill (it
120
+ # ships in spec.files, so only a broken install) still gets a pointer, not a crash.
121
+ def self.shared_guidance(name)
122
+ @shared_guidance ||= {}
123
+ @shared_guidance[name] ||= begin
124
+ text = File.read(SKILL_PATH)
125
+ pattern = /<!-- fp-shared:begin #{Regexp.escape(name)} -->.*?<!-- fp-shared:end #{Regexp.escape(name)} -->/m
126
+ text[pattern] || "See the `fp` agent skill (#{name})."
127
+ rescue SystemCallError
128
+ "See the `fp` agent skill bundled with the featureparity gem (#{name})."
129
+ end
130
+ end
131
+
100
132
  private
101
133
 
134
+ def shared_guidance(name)
135
+ self.class.shared_guidance(name)
136
+ end
137
+
138
+ # `fp init --detect`: what the repo looks like, nothing written.
139
+ def print_detection(detection)
140
+ output.success(detection) do
141
+ RepoDetector.print_summary(detection)
142
+ puts
143
+ puts 'Nothing was written. To onboard with these suggestions:'
144
+ keys = detection['surfaces'].map { |s| s['key'] }
145
+ surfaces = keys.empty? ? '<keys>' : keys.join(',')
146
+ puts " fp projects create #{detection['slug']} --name \"...\" --surfaces #{surfaces} # if it doesn't exist yet"
147
+ puts " fp init --project #{detection['slug']}#{keys.length == 1 ? " --surface #{keys.first}" : ''}"
148
+ end
149
+ end
150
+
102
151
  def write_project_config(root, project_slug, level, opts, written, skipped)
103
152
  path = File.join(root, ProjectConfig::FILENAME)
104
153
  body = ProjectConfig.render(
@@ -196,8 +245,11 @@ module Fp
196
245
  puts ' 2. Add the FP_API_KEY secret to the repo so CI can report evidence and run the gate.'
197
246
  puts " 3. Try the gate locally: fp check#{opts[:surface] ? " --surface #{opts[:surface]}" : ''}"
198
247
  puts
199
- puts 'Until the FP_API_KEY secret is added, the gate skips with an explanation'
200
- puts 'instead of failing — so the first push that onboards the repo stays green.'
248
+ puts 'Commit these files now, even before the FP_API_KEY secret exists: until it is'
249
+ puts 'added, the gate skips with an explanation instead of failing, so the first push'
250
+ puts 'that onboards the repo stays green.'
251
+ puts
252
+ print_test_guidance
201
253
  puts
202
254
  puts 'The gate starts at the `regression` level, which only fails on tests that have'
203
255
  puts 'gone red — it will not block you on requirements that were never covered. Raise'
@@ -205,10 +257,100 @@ module Fp
205
257
  end
206
258
  end
207
259
 
260
+ # The detected test-report command(s) and the database note, so the first report
261
+ # doesn't start with working out how to get a report out of the suite.
262
+ def print_test_guidance
263
+ detection = @detection || {}
264
+ runners = detection['test_runners'] || []
265
+ if runners.empty?
266
+ puts 'No test runner recognised. Any runner that writes JUnit XML works: fp report --junit <file>.'
267
+ else
268
+ puts 'To report evidence from your tests:'
269
+ runners.each do |r|
270
+ puts " #{r['name']}: #{r['fp_run'] || r['report_command']}"
271
+ puts " or write the report yourself: #{r['report_command']}" if r['fp_run']
272
+ puts " needs #{r['dependency']}" if r['dependency']
273
+ end
274
+ end
275
+ note = RepoDetector.database_note(detection['database'])
276
+ if note
277
+ puts
278
+ puts note
279
+ puts "A devcontainer is at #{detection['devcontainer']} and may provide it locally." if detection['devcontainer']
280
+ end
281
+ puts
282
+ puts 'Run `fp doctor` any time to check fp, its config and this repo.'
283
+ end
284
+
285
+ # A commented-out service for the report job when the tests need a database server,
286
+ # since the recipe can't run them without one.
287
+ DATABASE_SERVICES = {
288
+ 'postgresql' => <<~YAML,
289
+ # services:
290
+ # postgres:
291
+ # image: postgres:16
292
+ # env:
293
+ # POSTGRES_PASSWORD: postgres
294
+ # ports: ['5432:5432']
295
+ # options: >-
296
+ # --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
297
+ YAML
298
+ 'mysql' => <<~YAML,
299
+ # services:
300
+ # mysql:
301
+ # image: mysql:8
302
+ # env:
303
+ # MYSQL_ROOT_PASSWORD: root
304
+ # ports: ['3306:3306']
305
+ # options: >-
306
+ # --health-cmd "mysqladmin ping" --health-interval 10s --health-timeout 5s --health-retries 5
307
+ YAML
308
+ 'mongodb' => <<~YAML
309
+ # services:
310
+ # mongodb:
311
+ # image: mongo:7
312
+ # ports: ['27017:27017']
313
+ YAML
314
+ }.freeze
315
+
316
+ def database_lines
317
+ db = (@detection || {})['database']
318
+ return '' unless db && db['needed']
319
+
320
+ kinds = db['kinds'] & RepoDetector::SERVER_DATABASES
321
+ services = kinds.filter_map { |k| DATABASE_SERVICES[k] }.first.to_s
322
+ indent = ' '
323
+ lines = ["# # TODO: the tests need #{kinds.join('/')} (#{db['evidence'].first(3).join('; ')}).",
324
+ '# # This job has no database service: uncomment and adapt the one below, and point',
325
+ '# # the app at it (e.g. DATABASE_URL), or the suite cannot run here.']
326
+ (lines + services.lines.map(&:chomp)).map { |l| "#{indent}#{l}\n" }.join
327
+ end
328
+
329
+ def test_command_line
330
+ runner = ((@detection || {})['test_runners'] || []).first
331
+ return 'echo "replace with your test command emitting junit.xml"' unless runner
332
+
333
+ runner['report_command']
334
+ end
335
+
336
+ def report_file
337
+ runner = ((@detection || {})['test_runners'] || []).first
338
+ runner && !runner['report_file'].to_s.end_with?('test-results') ? runner['report_file'] : 'junit.xml'
339
+ end
340
+
208
341
  # The CI gate and its adaptable reporting recipe are separate concerns: the gate
209
342
  # blocks pull requests, while reporting needs a trusted context with FP_API_KEY and
210
343
  # should cover both pull requests and default-branch pushes.
344
+ #
345
+ # The recipe's test step uses the report command detected for this repo, and when the
346
+ # tests need a database server a commented-out service goes in ahead of its steps.
211
347
  def workflow_body
348
+ body = workflow_template
349
+ db = database_lines
350
+ db.empty? ? body : body.sub(" # steps:\n", "#{db} # steps:\n")
351
+ end
352
+
353
+ def workflow_template
212
354
  <<~YAML
213
355
  # FeatureParity — parity enforcement.
214
356
  #
@@ -350,7 +492,7 @@ module Fp
350
492
  # steps:
351
493
  # - uses: actions/checkout@v4
352
494
  # - name: Run tests (emit JUnit)
353
- # run: echo "replace with your test command emitting junit.xml"
495
+ # run: #{test_command_line}
354
496
  # - name: Report to FeatureParity
355
497
  # if: always() && env.HAVE_FP_KEY == 'true'
356
498
  # env:
@@ -358,7 +500,7 @@ module Fp
358
500
  # run: |
359
501
  # gem install featureparity
360
502
  # args=(
361
- # --junit junit.xml
503
+ # --junit #{report_file}
362
504
  # --surface <surface-key>
363
505
  # --ci-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
364
506
  # )
@@ -377,6 +519,10 @@ module Fp
377
519
  #{AGENTS_MARKER}
378
520
  ## FeatureParity (this project tracks feature parity)
379
521
 
522
+ _Written by fp #{Fp::VERSION} (`fp init`). `fp init --force` refreshes it, and `fp doctor`
523
+ says when the installed fp is newer. The blocks between `fp-shared` comments are the
524
+ `fp` agent skill's own text: if you have the skill loaded, you've read them already._
525
+
380
526
  This repo is tracked by [FeatureParity](https://featureparity.dev) as project
381
527
  **`#{project_slug}`** (see `.featureparity.yml`). Requirements are shared across every
382
528
  surface the product ships on, and CI enforces that they stay covered — a pull request
@@ -388,6 +534,9 @@ module Fp
388
534
  2. **The requirements stay true.** If none covers what you're building, propose one.
389
535
  If one is wrong or outdated, propose the change — never edit or ignore it.
390
536
 
537
+ **First run in this repo?** The loop below assumes the catalog already describes the
538
+ product. If it doesn't yet, follow *Onboarding a repo* near the end of this section first.
539
+
391
540
  **Start every task by checking the requirements — before you write code, not
392
541
  after.** When you pick up a feature or bug, first inventory the full request and
393
542
  compare it with the active and draft requirements. Find what already covers the work,
@@ -405,66 +554,7 @@ module Fp
405
554
  fp changes --source-url URL # requirement activity prompted by a work item
406
555
  ```
407
556
 
408
- **The requirement catalog is the human-readable product specification, not a box to
409
- tick with one umbrella record.** It records the team's current shared understanding;
410
- it is not a finished decree handed from product to engineering. Refine requirements
411
- through ongoing conversation with customers, domain experts, and builders. The human
412
- product owner decides value and priority; agents should surface gaps, inconsistencies,
413
- alternatives, and useful splits rather than silently making product decisions.
414
-
415
- Before coding, enumerate the independently meaningful promises in the request:
416
- user-visible behaviours, policies, limits, plans, deadlines, state transitions,
417
- permissions, failure modes, and affected surfaces. Then model them:
418
-
419
- - Give each independently testable or independently failable product promise its own
420
- focused requirement. If one acceptance sentence joins behaviours that could pass or
421
- fail separately, split it.
422
- - Use a parent for the broad capability and children for distinct behaviours, policies,
423
- or surface-specific promises. Do not create requirements for implementation details
424
- or refactors that make no product promise.
425
- - Put concrete product decisions — prices, quotas, dates, roles, fallbacks — in the
426
- requirement acceptance criteria. Never leave them only as assumptions in code or a PR.
427
- - Check adjacent requirements and their history. New requirements do not excuse an old
428
- requirement that the work makes inaccurate; propose a change to keep the whole catalog true.
429
- - Before finishing, reconcile the implementation against the catalog: every shipped
430
- behaviour maps to a current requirement, every changed promise is recorded, and every
431
- focused requirement has appropriate marked evidence or an explicit gap. A broad marker
432
- must never appear to prove several acceptance clauses that its test does not exercise.
433
-
434
- **Prevent contradictions before they enter the catalog.** Before proposing a new or changed
435
- promise, compare its intended acceptance criteria with related active and draft requirements
436
- and read their history. Ask whether both promises can be true at the same time for the same
437
- user, state, and conditions. If not, do not add another requirement.
438
-
439
- A failing test bound to an existing requirement, or an implementation that only works by
440
- changing one, is a contradiction signal — not permission to weaken the test or write a
441
- second requirement around it. Identify which promise matches product intent, then use
442
- `fp propose-change` (or `--removal`) with the source and reason to put that decision before
443
- a human. If the intent is unclear, stop and ask rather than making the catalog say both things.
444
-
445
- **Treat existing tests as protected product evidence.** Before editing an existing test,
446
- determine what expectation it already records and whether CI has passing evidence for it.
447
-
448
- - If the test backs passing evidence, do not rewrite it to make the current implementation
449
- or a new requirement fit. Explicitly flag the intended test edit in the work item and pull
450
- request. If its expected behaviour would change, propose the requirement change and wait
451
- for explicit human approval before editing the test; a newly green run must not hide that
452
- the proof itself changed.
453
- - When FeatureParity reports a changed bound test, run `fp changed-tests` (or add `--json`)
454
- and compare the accepted and candidate source revisions with the requirement. Record the
455
- exact candidate through `fp changed-tests assess <id> --candidate-fingerprint <sha256>`
456
- with a plain-language explanation and one classification: `still_proves`,
457
- `no_longer_proves`, or `uncertain`. The first resolves only that fingerprint; the others
458
- remain open for a human product decision. A later fingerprint needs a new assessment.
459
- - A new requirement does not retroactively own or supersede a pre-existing failing test —
460
- whether the requirement is draft or active. Do not change that test, or adopt it as the new
461
- requirement's proof, merely to make the new work pass. Surface the conflict and wait for
462
- explicit human approval to change the existing expectation.
463
-
464
- A changed-test finding is an audit signal alongside the testcase's independent CI outcome.
465
- It is not a failed test and is separate from `suspect`, which means requirement wording
466
- changed and its evidence needs re-verification. Neither permits rewriting a test before
467
- product intent is decided.
557
+ #{shared_guidance('modeling')}
468
558
 
469
559
  If no requirement covers what you're building, propose one or a parent/child tree. Agents
470
560
  always create **drafts**; a human activates them:
@@ -475,56 +565,7 @@ module Fp
475
565
  --source-type fizzy --source-url <card-or-issue-url>
476
566
  ```
477
567
 
478
- **Write a requirement as a product decision someone can approve, not as a design
479
- task.** It should read like a capable teammate wrote it: direct, specific, and free
480
- of boilerplate such as “ensure a seamless experience.”
481
-
482
- - **Title:** one clear product promise in present tense, with a concrete subject and
483
- active verb. Name the user-visible capability, not the work (`“Dispatchers see
484
- current shipment status”`, not `“Implement status polling”`).
485
- - **Why:** one natural sentence naming who needs this, the problem or risk, and the
486
- outcome worth protecting. Do not repeat the title, narrate the request or bug
487
- history, sell the feature, or explain the mechanism.
488
- - **Acceptance:** one independently verifiable rule of observable behaviour that lets a
489
- reviewer decide whether the promise is true. State the trigger or situation, the result, and material
490
- boundaries that qualify that same promise. Given/When/Then may clarify those parts,
491
- but it is not a mandatory template. Split a second result that could fail
492
- independently into its own requirement instead of comma-chaining it here.
493
- - **Example:** supply one concise concrete case that would pass the general rule, and
494
- put it after the rule in Acceptance as `Example: ...`. Use the domain's real terms
495
- and representative data, but ask rather than invent consequential policy. Do not mistake a few examples for the rule
496
- or let marked tests stand for every case; neither replaces the general rule and
497
- continued conversation.
498
- - **Numbers and policy:** include a price, limit, timeout, date, role, or fallback only
499
- when the source made it a real product decision. Never invent a plausible value to
500
- fill a gap. If the decision matters and the source is silent, ask.
501
- - **Implementation detail:** leave out components, data models, algorithms, endpoints,
502
- and UI controls unless that interface or constraint is itself part of the promise.
503
-
504
- Learn from the request, existing requirements and history, and available evidence about
505
- what users actually do. Look for what they attempt, where they abandon a task, what they
506
- use, and what experiments reveal. Treat the first proposed solution as input, not
507
- unquestionable scope. Preserve important uncertainty as a question rather than
508
- laundering a guess into acceptance criteria.
509
-
510
- State the promise itself rather than commentary about it. Avoid openings such as “This
511
- feature…”, “This requirement…”, “The goal is…”, “Users should be able to…”, and “The
512
- system should…”. Use the real actor or product as the subject and an active verb.
513
-
514
- - ✅ **Title:** `Dispatchers see current shipment status`
515
- **Why:** `Dispatchers need to spot delayed shipments before they miss a delivery window.`
516
- **Acceptance:** `When a shipment's status changes, the dashboard shows the new status; if status is unavailable, it says so instead of presenting stale data as current.`
517
- - ❌ **Title:** `Implement Redis-backed 30-second status polling`
518
- **Why:** `This important feature ensures a seamless, robust experience.`
519
- **Acceptance:** `Use a background worker and refresh every 30 seconds.`
520
- This invents policy, prescribes a design, and never states the product outcome.
521
-
522
- Before proposing, answer three questions against the actual fields: Who is this promise
523
- for? How would the concrete example prove it done? Why does the outcome matter? If any
524
- answer is missing or vague, revise the draft or ask the user. Then read it as a manager deciding scope:
525
- is the value clear, can the result be observed, are product decisions
526
- distinguished from guesses, and does every sentence earn its place? If the draft fails
527
- this review, revise it or ask before writing the record.
568
+ #{shared_guidance('writing')}
528
569
 
529
570
  ### 2. If the requirement is wrong, propose a change — don't silently edit it
530
571
 
@@ -579,6 +620,10 @@ module Fp
579
620
  --work-item <card-or-issue-url>
580
621
  ```
581
622
 
623
+ `--junit` also takes RSpec's own `--format json` report. To run the suite and report in
624
+ one step with no formatter added to the project (rspec, pytest, vitest, playwright):
625
+ `fp run --surface <surface> --repo <org/repo> -- <test command>`.
626
+
582
627
  Report `present` or `stub` only — **never** claim `passing`/`failing`. Those are CI's
583
628
  to write, via `fp ci-report`, and the gate trusts them precisely because agents can't
584
629
  set them.
@@ -586,6 +631,8 @@ module Fp
586
631
  If you just changed a requirement's wording and re-wrote its tests, re-report the
587
632
  evidence: that's what clears `suspect`.
588
633
 
634
+ #{shared_guidance('media')}
635
+
589
636
  ### 5. Confirm you didn't break the gate
590
637
 
591
638
  ```bash
@@ -594,6 +641,14 @@ module Fp
594
641
 
595
642
  `fp check --json` if you want to parse the violations. Run `fp help` for the full
596
643
  command list.
644
+
645
+ ### Onboarding a repo (first run)
646
+
647
+ #{shared_guidance('onboarding')}
648
+
649
+ ### Troubleshooting
650
+
651
+ #{shared_guidance('troubleshooting')}
597
652
  #{AGENTS_END_MARKER}
598
653
  MARKDOWN
599
654
  end
@@ -16,6 +16,7 @@ module Fp
16
16
  help_example 'fp list --project stowzilla'
17
17
  help_example 'fp list --project stowzilla --status draft', 'Drafts are hidden by default'
18
18
  help_example 'fp list --project stowzilla --gaps --surface customer_android'
19
+ help_example 'fp list --status draft --gaps', 'Drafts lacking evidence on a required surface'
19
20
 
20
21
  KNOWN_FLAGS = {
21
22
  project: :string,
@@ -36,7 +37,9 @@ module Fp
36
37
  # rather than passing --surface on every call.
37
38
  opts[:surface] ||= project_config.surface
38
39
 
39
- if opts[:gaps]
40
+ if opts[:gaps] && opts[:status] == 'draft'
41
+ list_draft_gaps(project_id, opts)
42
+ elsif opts[:gaps]
40
43
  list_gaps(project_id, opts)
41
44
  else
42
45
  list_requirements(project_id, opts)
@@ -191,7 +194,44 @@ module Fp
191
194
  render_gaps(gaps, report, opts)
192
195
  end
193
196
 
194
- def render_gaps(gaps, report, opts)
197
+ # Draft gaps: `--status draft --gaps`. The gate doesn't measure never-approved
198
+ # drafts, so the plain gaps read above can't see them and this used to print "No
199
+ # gaps found" on a project whose every requirement was an untested draft (Fizzy
200
+ # #1786). The parity report's `drafts` section resolves each draft's cells from
201
+ # evidence the way the grid would; a gap here is the same thing it is above — a
202
+ # cell in a state that violates at `covered` (missing, stub, failing) — read from
203
+ # the report's own violating_states rather than decided here. Informational: drafts
204
+ # never fail a gate.
205
+ def list_draft_gaps(project_id, opts)
206
+ result = client.get_parity_with_drafts(project_id, level: 'covered', surface: opts[:surface])
207
+
208
+ unless result[:ok]
209
+ output.error(result[:error], status: result[:status])
210
+ exit 1
211
+ end
212
+
213
+ report = result[:data]['parity'] || {}
214
+ unless report.key?('drafts')
215
+ output.error('This FeatureParity API does not report draft coverage yet (it predates ' \
216
+ '`include_drafts`), so draft gaps cannot be listed.')
217
+ exit 1
218
+ end
219
+
220
+ gap_states = report['violating_states'] || []
221
+ gaps = (report['drafts'] || []).filter_map do |row|
222
+ uncovered = Array(row['cells']).select { |c| gap_states.include?(c['state']) }
223
+ next if uncovered.empty?
224
+
225
+ { 'slug' => row['slug'], 'title' => row['title'], 'category' => row['category'],
226
+ 'under_review' => false,
227
+ 'surfaces' => uncovered.map { |c| { 'surface' => c['surface'], 'state' => c['state'] } } }
228
+ end
229
+ gaps = gaps.select { |gap| gap['category'] == opts[:category] } if opts[:category]
230
+
231
+ render_gaps(gaps, report, opts, drafts: true)
232
+ end
233
+
234
+ def render_gaps(gaps, report, opts, drafts: false)
195
235
  payload = {
196
236
  gaps: gaps,
197
237
  project: opts[:project],
@@ -199,9 +239,18 @@ module Fp
199
239
  category: opts[:category],
200
240
  summary: report['summary']
201
241
  }
242
+ payload[:status] = 'draft' if drafts
202
243
 
203
244
  output.success(payload) do
204
- if gaps.empty?
245
+ if gaps.empty? && drafts
246
+ filter_desc = []
247
+ filter_desc << "category: #{opts[:category]}" if opts[:category]
248
+ filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
249
+ msg = (report['drafts'] || []).empty? ? 'No draft requirements' : 'No draft gaps found'
250
+ msg += " (#{filter_desc.join(', ')})" unless filter_desc.empty?
251
+ puts "#{msg}."
252
+ next
253
+ elsif gaps.empty?
205
254
  filter_desc = []
206
255
  filter_desc << "category: #{opts[:category]}" if opts[:category]
207
256
  filter_desc << "surface: #{opts[:surface]}" if opts[:surface]
@@ -217,7 +266,7 @@ module Fp
217
266
  next
218
267
  end
219
268
 
220
- desc = "Gaps for project '#{opts[:project]}'"
269
+ desc = "#{drafts ? 'Draft gaps' : 'Gaps'} for project '#{opts[:project]}'"
221
270
  desc += " (surface: #{opts[:surface]})" if opts[:surface]
222
271
  desc += " (category: #{opts[:category]})" if opts[:category]
223
272
  puts "#{desc}:"
@@ -234,6 +283,11 @@ module Fp
234
283
  puts
235
284
  puts "#{Parity::UNDER_REVIEW} #{Parity::UNDER_REVIEW_LEGEND} — check `fp history <slug>` before writing a test against wording that may be about to move."
236
285
  end
286
+
287
+ if drafts
288
+ puts
289
+ puts 'Drafts are reported, never gated. See every draft × surface with: fp coverage'
290
+ end
237
291
  end
238
292
  end
239
293