featureparity 0.0.5 → 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.
- checksums.yaml +4 -4
- data/lib/fp/cli.rb +9 -2
- data/lib/fp/client.rb +66 -10
- data/lib/fp/commands/base.rb +49 -0
- data/lib/fp/commands/check.rb +216 -0
- data/lib/fp/commands/ci_report.rb +115 -0
- data/lib/fp/commands/export.rb +144 -0
- data/lib/fp/commands/help.rb +79 -1
- data/lib/fp/commands/history.rb +188 -0
- data/lib/fp/commands/init.rb +384 -0
- data/lib/fp/commands/list.rb +111 -31
- data/lib/fp/commands/matrix.rb +82 -42
- data/lib/fp/commands/propose.rb +1 -1
- data/lib/fp/commands/propose_change.rb +181 -0
- data/lib/fp/commands/report.rb +10 -6
- data/lib/fp/commands/repos.rb +3 -3
- data/lib/fp/commands/show.rb +5 -2
- data/lib/fp/commands/surfaces.rb +2 -2
- data/lib/fp/commands.rb +6 -0
- data/lib/fp/junit.rb +13 -4
- data/lib/fp/parity.rb +80 -0
- data/lib/fp/project_config.rb +137 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +2 -0
- data/skills/fp/SKILL.md +109 -1
- data/skills/fp/references/cli.md +203 -6
- metadata +10 -2
|
@@ -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
|
data/lib/fp/commands/list.rb
CHANGED
|
@@ -15,9 +15,13 @@ module Fp
|
|
|
15
15
|
|
|
16
16
|
def run(args)
|
|
17
17
|
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
18
|
-
|
|
18
|
+
project_config = ProjectConfig.discover
|
|
19
|
+
project_slug = require_project!(opts, project_config)
|
|
20
|
+
project_id = resolve_project_id(project_slug)
|
|
19
21
|
|
|
20
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|