featureparity 0.0.14 → 0.0.16
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 +5 -1
- data/lib/fp/client.rb +92 -0
- data/lib/fp/commands/attach.rb +190 -0
- data/lib/fp/commands/attachments.rb +122 -0
- data/lib/fp/commands/changes.rb +359 -0
- data/lib/fp/commands/check.rb +30 -7
- data/lib/fp/commands/ci_report.rb +5 -1
- data/lib/fp/commands/conversation.rb +92 -0
- data/lib/fp/commands/help.rb +65 -9
- data/lib/fp/commands/init.rb +106 -21
- data/lib/fp/commands/propose.rb +23 -2
- data/lib/fp/commands/propose_change.rb +13 -5
- data/lib/fp/commands/report.rb +19 -3
- data/lib/fp/commands/show.rb +34 -2
- data/lib/fp/commands.rb +4 -0
- data/lib/fp/junit.rb +114 -97
- data/lib/fp/version.rb +1 -1
- data/skills/fp/SKILL.md +112 -25
- data/skills/fp/references/cli.md +88 -24
- data/skills/fp/references/markers.md +25 -0
- metadata +6 -2
data/lib/fp/junit.rb
CHANGED
|
@@ -32,8 +32,13 @@ module Fp
|
|
|
32
32
|
# One requirement's aggregated CI outcome for one surface, ready to hand to the
|
|
33
33
|
# backend's ingest endpoint. This is what lets CI upload *resolved* results rather
|
|
34
34
|
# than raw XML the server would have to re-scrape the repo to understand.
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
#
|
|
36
|
+
# `title` names a representative test — the first one that *failed* when the verdict
|
|
37
|
+
# is `failing`, otherwise the first that ran. Without it the evidence row keeps
|
|
38
|
+
# whatever title an earlier agent report left behind, and the web app then blames a
|
|
39
|
+
# test that is perfectly green for the failure.
|
|
40
|
+
CiResult = Struct.new(:slug, :surface, :test_type, :status, :file, :line, :title, :test_count,
|
|
41
|
+
:failure_count, keyword_init: true)
|
|
37
42
|
|
|
38
43
|
# A failing testcase that carries no fp:<slug> marker at all — a "loose end". Not a
|
|
39
44
|
# cell on the parity grid (nothing binds it to a requirement) and not an orphan
|
|
@@ -46,9 +51,13 @@ module Fp
|
|
|
46
51
|
# A testcase paired with the marker that annotates it. `precise` records whether we
|
|
47
52
|
# know *which* test the marker sits above (matched by line or by test name) or only
|
|
48
53
|
# that the marker is somewhere in the same file — a title is only worth reporting in
|
|
49
|
-
# the first case.
|
|
50
|
-
#
|
|
51
|
-
#
|
|
54
|
+
# the first case.
|
|
55
|
+
#
|
|
56
|
+
# `repo_file` is the testcase's source file expressed relative to the *repository
|
|
57
|
+
# root*, which is what gets reported. The path in the report is relative to whatever
|
|
58
|
+
# directory the runner was invoked from (vitest reports `src/pages/Foo.test.jsx` from
|
|
59
|
+
# inside `frontend/`), so reporting it verbatim produces a link to a file that doesn't
|
|
60
|
+
# exist at that path in the repo.
|
|
52
61
|
Match = Struct.new(:testcase, :marker, :precise, :repo_file, keyword_init: true)
|
|
53
62
|
|
|
54
63
|
# Evidence discovered by binding a testcase to a marker.
|
|
@@ -56,18 +65,24 @@ module Fp
|
|
|
56
65
|
# testcase's own line when the runner reported one, otherwise the line of
|
|
57
66
|
# the fp:<slug> marker that annotates it (markers sit directly above the
|
|
58
67
|
# test, so the marker line is a close stand-in when the runner omits lines).
|
|
59
|
-
Binding = Struct.new(:slug, :surface, :file, :line, :title, :state, keyword_init: true)
|
|
68
|
+
Binding = Struct.new(:slug, :surface, :test_type, :file, :line, :title, :state, keyword_init: true)
|
|
60
69
|
|
|
61
70
|
# Matches `fp:<slug>` inside a comment, with an optional `@surface` (or
|
|
62
|
-
# `@surface1,surface2`) suffix to target one or more specific surfaces
|
|
71
|
+
# `@surface1,surface2`) suffix to target one or more specific surfaces, and an
|
|
72
|
+
# optional `:<type>` suffix to pin the kind of test:
|
|
63
73
|
#
|
|
64
|
-
# fp:print_qr #
|
|
74
|
+
# fp:print_qr # --surface flag default, unspecified type
|
|
65
75
|
# fp:print_qr@api # reports evidence for the `api` surface
|
|
66
76
|
# fp:print_qr@api,web # reports for both `api` and `web`
|
|
77
|
+
# fp:print_qr@api:e2e # the `api` surface, filed as an e2e test
|
|
78
|
+
# fp:print_qr:unit # --surface default, filed as a unit test
|
|
79
|
+
# fp:print_qr@api,web:e2e # both surfaces, each filed as an e2e test
|
|
67
80
|
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
|
|
81
|
+
# The type pins the kind of test (unit, integration, e2e, manual, other) so a
|
|
82
|
+
# requirement that demands several kinds can tell them apart. It applies to every
|
|
83
|
+
# surface the marker names. Slugs, surfaces and types are lowercase letters, digits,
|
|
84
|
+
# underscores and hyphens (matching the propose/report convention).
|
|
85
|
+
MARKER_RE = /fp:([a-z0-9][a-z0-9_-]*)(?:@([a-z0-9_-]+(?:,[a-z0-9_-]+)*))?(?::([a-z0-9_-]+))?/.freeze
|
|
71
86
|
|
|
72
87
|
# A marker only counts when it is a comment on its own line — the documented
|
|
73
88
|
# convention ("the fp:<slug> comment sits immediately above its test"). Requiring a
|
|
@@ -176,7 +191,7 @@ module Fp
|
|
|
176
191
|
# unmatched: [TestCase, ...], # testcases with no discoverable marker
|
|
177
192
|
# missing_surface: [Binding-ish], # markers with no surface and no default
|
|
178
193
|
# }
|
|
179
|
-
def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil)
|
|
194
|
+
def bind_markers(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil)
|
|
180
195
|
bindings = {}
|
|
181
196
|
matched = {}
|
|
182
197
|
missing_surface = []
|
|
@@ -189,11 +204,17 @@ module Fp
|
|
|
189
204
|
surfaces = marker[:surfaces]
|
|
190
205
|
surfaces = [default_surface] if surfaces.empty?
|
|
191
206
|
|
|
207
|
+
# The marker's own type pin wins; otherwise the --test-type default (nil → the
|
|
208
|
+
# server files it as `unspecified`).
|
|
209
|
+
test_type = marker[:test_type] || default_test_type
|
|
210
|
+
|
|
192
211
|
surfaces.each do |surface|
|
|
193
212
|
if surface.nil? || surface.empty?
|
|
194
213
|
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
195
214
|
else
|
|
196
|
-
record_binding(bindings, marker[:slug], surface, tc, marker,
|
|
215
|
+
record_binding(bindings, marker[:slug], surface, tc, marker,
|
|
216
|
+
titles_reliable: match.precise, file: match.repo_file,
|
|
217
|
+
test_type: test_type)
|
|
197
218
|
end
|
|
198
219
|
end
|
|
199
220
|
end
|
|
@@ -214,7 +235,7 @@ module Fp
|
|
|
214
235
|
# it was skipped (a skipped test is not proof of anything), and `passing` otherwise.
|
|
215
236
|
#
|
|
216
237
|
# Returns { results: [CiResult, ...], unmatched: [TestCase], missing_surface: [...] }.
|
|
217
|
-
def ci_results(testcases, base_dir: Dir.pwd, default_surface: nil)
|
|
238
|
+
def ci_results(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil)
|
|
218
239
|
aggregates = {}
|
|
219
240
|
matched = {}
|
|
220
241
|
missing_surface = []
|
|
@@ -227,31 +248,47 @@ module Fp
|
|
|
227
248
|
surfaces = marker[:surfaces]
|
|
228
249
|
surfaces = [default_surface] if surfaces.empty?
|
|
229
250
|
|
|
251
|
+
# The marker's own type pin wins; otherwise the --test-type default (nil → the
|
|
252
|
+
# server files it as `unspecified`). One CI verdict per (slug, surface, type).
|
|
253
|
+
test_type = marker[:test_type] || default_test_type
|
|
254
|
+
|
|
230
255
|
surfaces.each do |surface|
|
|
231
256
|
if surface.nil? || surface.empty?
|
|
232
257
|
missing_surface << { slug: marker[:slug], file: tc.file }
|
|
233
258
|
next
|
|
234
259
|
end
|
|
235
260
|
|
|
236
|
-
key = [marker[:slug], surface]
|
|
237
|
-
agg = aggregates[key] ||= { file: match.repo_file
|
|
261
|
+
key = [marker[:slug], surface, test_type]
|
|
262
|
+
agg = aggregates[key] ||= { file: match.repo_file, line: tc.line || marker[:line],
|
|
263
|
+
title: nil, failing_title: nil, ran: 0, failed: 0, skipped: 0 }
|
|
238
264
|
if tc.stub?
|
|
239
265
|
agg[:skipped] += 1
|
|
240
266
|
else
|
|
241
267
|
agg[:ran] += 1
|
|
242
268
|
agg[:failed] += 1 if tc.failed?
|
|
243
269
|
end
|
|
270
|
+
|
|
271
|
+
# Name a test for the evidence row. The first *failing* test is the one a human
|
|
272
|
+
# wants to see when the cell goes red; otherwise the first that ran will do.
|
|
273
|
+
# Only when we know which test the marker annotates — a guess here is worse
|
|
274
|
+
# than no title.
|
|
275
|
+
if match.precise && (title = presence(tc.name))
|
|
276
|
+
agg[:title] ||= title
|
|
277
|
+
agg[:failing_title] ||= title if tc.failed?
|
|
278
|
+
end
|
|
244
279
|
end
|
|
245
280
|
end
|
|
246
281
|
|
|
247
|
-
results = aggregates.map do |(slug, surface), agg|
|
|
282
|
+
results = aggregates.map do |(slug, surface, test_type), agg|
|
|
248
283
|
status =
|
|
249
284
|
if agg[:failed].positive? then 'failing'
|
|
250
285
|
elsif agg[:ran].zero? then 'stub'
|
|
251
286
|
else 'passing'
|
|
252
287
|
end
|
|
253
288
|
|
|
254
|
-
CiResult.new(slug: slug, surface: surface,
|
|
289
|
+
CiResult.new(slug: slug, surface: surface, test_type: test_type, status: status,
|
|
290
|
+
file: agg[:file], line: agg[:line],
|
|
291
|
+
title: agg[:failing_title] || agg[:title],
|
|
255
292
|
test_count: agg[:ran], failure_count: agg[:failed])
|
|
256
293
|
end
|
|
257
294
|
|
|
@@ -276,17 +313,14 @@ module Fp
|
|
|
276
313
|
# can never disagree about which test a marker annotates.
|
|
277
314
|
def walk_matches(testcases, base_dir)
|
|
278
315
|
marker_cache = {}
|
|
279
|
-
|
|
316
|
+
root = repo_root_for(base_dir)
|
|
280
317
|
|
|
281
318
|
# Group testcases by their source file so we can reason about ordering.
|
|
282
319
|
testcases.group_by(&:file).each do |file, cases|
|
|
283
320
|
markers = markers_for(cases.first, base_dir, marker_cache)
|
|
284
321
|
next if markers.empty?
|
|
285
322
|
|
|
286
|
-
|
|
287
|
-
# so a GitHub blob link resolves. base_dir's offset below the root is cached, so
|
|
288
|
-
# this is one shell-out per run, not per file.
|
|
289
|
-
repo_file = repo_relative_file(file, base_dir, prefix_cache)
|
|
323
|
+
repo_file = repo_relative_path(file, base_dir, root)
|
|
290
324
|
|
|
291
325
|
cases.each do |tc|
|
|
292
326
|
marker, precise = marker_for(tc, cases, markers)
|
|
@@ -297,6 +331,48 @@ module Fp
|
|
|
297
331
|
end
|
|
298
332
|
end
|
|
299
333
|
|
|
334
|
+
# The repository root containing `dir`, or nil when `dir` isn't inside one. #
|
|
335
|
+
# Found by walking up for a `.git` entry rather than shelling out to git, so it also
|
|
336
|
+
# works in a worktree (where `.git` is a file) and costs nothing.
|
|
337
|
+
def repo_root_for(dir)
|
|
338
|
+
current = File.expand_path(dir)
|
|
339
|
+
|
|
340
|
+
loop do
|
|
341
|
+
return current if File.exist?(File.join(current, '.git'))
|
|
342
|
+
|
|
343
|
+
parent = File.dirname(current)
|
|
344
|
+
return nil if parent == current
|
|
345
|
+
|
|
346
|
+
current = parent
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Express a report's test-file path relative to the repository root.
|
|
351
|
+
#
|
|
352
|
+
# The path in a JUnit report is relative to wherever the runner ran, which is not
|
|
353
|
+
# necessarily the repo root — vitest invoked in `frontend/` reports
|
|
354
|
+
# `src/pages/Matrix.test.jsx`, and `--base-dir frontend` is what makes that resolvable
|
|
355
|
+
# locally. Evidence is rendered as a GitHub link built from the repo plus this path,
|
|
356
|
+
# so reporting it verbatim links to a path that doesn't exist (a 404).
|
|
357
|
+
#
|
|
358
|
+
# Falls back to the path as given when there's no repo to relativise against, or when
|
|
359
|
+
# the file resolves outside it.
|
|
360
|
+
def repo_relative_path(file, base_dir, root = repo_root_for(base_dir))
|
|
361
|
+
return file if file.nil? || file.empty? || root.nil?
|
|
362
|
+
|
|
363
|
+
absolute = File.expand_path(file, base_dir)
|
|
364
|
+
prefix = "#{root}#{File::SEPARATOR}"
|
|
365
|
+
return file unless absolute.start_with?(prefix)
|
|
366
|
+
|
|
367
|
+
absolute.delete_prefix(prefix)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# nil for blank, the stripped value otherwise.
|
|
371
|
+
def presence(value)
|
|
372
|
+
stripped = value.to_s.strip
|
|
373
|
+
stripped.empty? ? nil : stripped
|
|
374
|
+
end
|
|
375
|
+
|
|
300
376
|
# Determine the marker (slug + surfaces) that annotates a testcase.
|
|
301
377
|
# Returns [marker, precise] — or [nil, false] when nothing can be tied to it.
|
|
302
378
|
def marker_for(testcase, sibling_cases, markers)
|
|
@@ -371,9 +447,12 @@ module Fp
|
|
|
371
447
|
candidate
|
|
372
448
|
end
|
|
373
449
|
|
|
374
|
-
def record_binding(bindings, slug, surface, testcase, marker,
|
|
375
|
-
|
|
376
|
-
|
|
450
|
+
def record_binding(bindings, slug, surface, testcase, marker, titles_reliable: true, file: nil,
|
|
451
|
+
test_type: nil)
|
|
452
|
+
rel_file = file || testcase.file
|
|
453
|
+
# test_type is part of the key: a unit test and an e2e test for the same
|
|
454
|
+
# slug/surface/file are distinct evidence rows, so they mustn't collapse into one.
|
|
455
|
+
key = [slug, surface, test_type, rel_file]
|
|
377
456
|
state = testcase.stub? ? 'stub' : 'present'
|
|
378
457
|
|
|
379
458
|
# The test's line: prefer the runner-reported testcase line; fall back to
|
|
@@ -385,8 +464,8 @@ module Fp
|
|
|
385
464
|
# Prefer a concrete 'present' state over 'stub' when a slug has both.
|
|
386
465
|
return if existing && !(existing.state == 'stub' && state == 'present')
|
|
387
466
|
|
|
388
|
-
bindings[key] = Binding.new(slug: slug, surface: surface,
|
|
389
|
-
title: (testcase.name if titles_reliable), state: state)
|
|
467
|
+
bindings[key] = Binding.new(slug: slug, surface: surface, test_type: test_type, file: rel_file,
|
|
468
|
+
line: line, title: (testcase.name if titles_reliable), state: state)
|
|
390
469
|
end
|
|
391
470
|
|
|
392
471
|
# Load and cache the fp:<slug> markers (with line numbers) for a testcase's
|
|
@@ -408,73 +487,10 @@ module Fp
|
|
|
408
487
|
cache[file] = markers
|
|
409
488
|
end
|
|
410
489
|
|
|
411
|
-
#
|
|
412
|
-
#
|
|
413
|
-
#
|
|
414
|
-
#
|
|
415
|
-
# `--base-dir frontend`. That base_dir is enough to *find* the markers, but the path
|
|
416
|
-
# we then store as `source_file` was the raw report path (`src/pages/Matrix.test.jsx`),
|
|
417
|
-
# not the repo-root path (`frontend/src/pages/Matrix.test.jsx`). The web app builds a
|
|
418
|
-
# GitHub link as `blob/<sha>/<source_file>`, and a blob path is always relative to the
|
|
419
|
-
# git root — so the missing `frontend/` prefix produced a 404 on every web test link.
|
|
420
|
-
#
|
|
421
|
-
# The fix is purely additive: find how far base_dir sits below the git root and
|
|
422
|
-
# prepend that. When base_dir *is* the git root (the Ruby suites, run from the repo
|
|
423
|
-
# root), the prefix is empty and the original path is returned byte-for-byte — so this
|
|
424
|
-
# only ever corrects a subdirectory base_dir and never rewrites a path that was
|
|
425
|
-
# already right. Falls back to the raw path when there's no git checkout (an agent
|
|
426
|
-
# running from a tarball) or base_dir sits outside one.
|
|
427
|
-
def repo_relative_file(file, base_dir, cache = {})
|
|
428
|
-
return file if file.nil? || file.empty?
|
|
429
|
-
|
|
430
|
-
prefix = base_dir_prefix(base_dir, cache)
|
|
431
|
-
return file if prefix.nil? || prefix.empty?
|
|
432
|
-
|
|
433
|
-
File.join(prefix, file)
|
|
434
|
-
end
|
|
435
|
-
|
|
436
|
-
# base_dir's path relative to the git root that contains it: '' when base_dir is the
|
|
437
|
-
# root, 'frontend' when it's a package below it, nil when base_dir isn't in a checkout
|
|
438
|
-
# (or sits above the root). Cached per base_dir so a report shells out once.
|
|
439
|
-
def base_dir_prefix(base_dir, cache)
|
|
440
|
-
absolute = File.expand_path(base_dir)
|
|
441
|
-
return cache[absolute] if cache.key?(absolute)
|
|
442
|
-
|
|
443
|
-
root = git_toplevel_for(absolute)
|
|
444
|
-
prefix =
|
|
445
|
-
if root.nil?
|
|
446
|
-
nil
|
|
447
|
-
else
|
|
448
|
-
rel = relative_path(absolute, root)
|
|
449
|
-
rel == '.' || rel.start_with?('..') ? '' : rel
|
|
450
|
-
end
|
|
451
|
-
cache[absolute] = prefix
|
|
452
|
-
end
|
|
453
|
-
|
|
454
|
-
# The git repo root containing `dir`, or nil if `dir` isn't inside a checkout.
|
|
455
|
-
def git_toplevel_for(dir)
|
|
456
|
-
out = `git -C #{quote(dir)} rev-parse --show-toplevel 2>/dev/null`
|
|
457
|
-
root = out.strip
|
|
458
|
-
($?&.success? && !root.empty?) ? File.expand_path(root) : nil
|
|
459
|
-
end
|
|
460
|
-
|
|
461
|
-
def relative_path(absolute, root)
|
|
462
|
-
require 'pathname'
|
|
463
|
-
Pathname.new(absolute).relative_path_from(Pathname.new(root)).to_s
|
|
464
|
-
rescue ArgumentError
|
|
465
|
-
# Different roots on Windows, or otherwise not relative — treat as outside.
|
|
466
|
-
'..'
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
# Minimal shell-quote for a path passed to `git -C`. The directory comes from
|
|
470
|
-
# --base-dir, not arbitrary report content, but quoting keeps spaces safe.
|
|
471
|
-
def quote(str)
|
|
472
|
-
"'#{str.gsub("'", "'\\\\''")}'"
|
|
473
|
-
end
|
|
474
|
-
|
|
475
|
-
# Scan file contents for fp:<slug>[@surface[,surface...]] markers, returning
|
|
476
|
-
# [{ slug:, surfaces: [..], line:, title:, title_kind: }, ...] (1-based line numbers).
|
|
477
|
-
# `surfaces` is [] when the marker pins none (use the default surface).
|
|
490
|
+
# Scan file contents for fp:<slug>[@surface[,surface...]][:type] markers, returning
|
|
491
|
+
# [{ slug:, surfaces: [..], test_type:, line:, title:, title_kind: }, ...]
|
|
492
|
+
# (1-based line numbers). `surfaces` is [] when the marker pins none (use the default
|
|
493
|
+
# surface). `test_type` is nil when the marker pins none (filed as `unspecified`).
|
|
478
494
|
#
|
|
479
495
|
# `title` is the name of the test (or describe block) declared below the marker, used
|
|
480
496
|
# to bind markers to results from runners that report no line numbers. It is nil when
|
|
@@ -487,10 +503,11 @@ module Fp
|
|
|
487
503
|
lines.each_with_index do |line, idx|
|
|
488
504
|
next unless line.match?(COMMENT_LINE_RE)
|
|
489
505
|
|
|
490
|
-
line.scan(MARKER_RE) do |(slug, surface_list)|
|
|
506
|
+
line.scan(MARKER_RE) do |(slug, surface_list, test_type)|
|
|
491
507
|
surfaces = surface_list ? surface_list.split(',').map(&:strip).reject(&:empty?) : []
|
|
492
508
|
title, kind = declared_test_after(lines, idx)
|
|
493
|
-
markers << { slug: slug, surfaces: surfaces,
|
|
509
|
+
markers << { slug: slug, surfaces: surfaces, test_type: presence(test_type),
|
|
510
|
+
line: idx + 1, title: title, title_kind: kind }
|
|
494
511
|
end
|
|
495
512
|
end
|
|
496
513
|
markers
|
data/lib/fp/version.rb
CHANGED
data/skills/fp/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fp
|
|
3
|
-
description: "Use the fp CLI
|
|
3
|
+
description: "Use the fp CLI as a complete product specification: write decision-ready requirements, inventory and decompose scope, manage changes through review, bind tests to focused promises, report evidence, and inspect parity."
|
|
4
4
|
license: MIT
|
|
5
5
|
compatibility: Requires the fp executable on PATH (gem install featureparity)
|
|
6
6
|
metadata:
|
|
@@ -41,17 +41,51 @@ If not set, ask the user for their API key or have them run `fp setup`.
|
|
|
41
41
|
|
|
42
42
|
## Core workflows
|
|
43
43
|
|
|
44
|
-
### 1.
|
|
44
|
+
### 1. Inventory and model the complete requirement scope
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
47
|
fp list --project stowzilla # All active requirements
|
|
48
|
+
fp list --project stowzilla --status draft # Proposed work; avoid duplicates
|
|
48
49
|
fp list --project stowzilla --gaps # Requirements with a required surface lacking evidence
|
|
49
|
-
fp show <slug> --project stowzilla # Requirement details
|
|
50
|
+
fp show <slug> --project stowzilla # Requirement details and evidence
|
|
51
|
+
fp history <slug> --project stowzilla # Origins and pending changes
|
|
52
|
+
fp changes --source-url <work-item-url> # Activity prompted by one card, issue, or thread
|
|
50
53
|
fp matrix --project stowzilla # ASCII parity matrix
|
|
51
54
|
fp matrix --project stowzilla --csv # Export as CSV
|
|
52
55
|
fp check --project stowzilla # Enforce parity (exits 1 on violations)
|
|
53
56
|
```
|
|
54
57
|
|
|
58
|
+
Treat FeatureParity as the human-readable product specification, not a bookkeeping step.
|
|
59
|
+
Before coding, inventory the entire request: user-visible behaviours, policies, limits,
|
|
60
|
+
plans, deadlines, state transitions, permissions, failure modes, and affected surfaces.
|
|
61
|
+
Compare all of it with active and draft requirements, including adjacent requirements the
|
|
62
|
+
change might make inaccurate.
|
|
63
|
+
|
|
64
|
+
Model that scope deliberately:
|
|
65
|
+
|
|
66
|
+
- One independently testable or independently failable product promise per focused
|
|
67
|
+
requirement. Split an acceptance sentence when its clauses could pass or fail separately.
|
|
68
|
+
- Use a parent for a broad capability and children for distinct behaviours, policies, or
|
|
69
|
+
surface-specific promises. Do not model implementation details with no product promise.
|
|
70
|
+
- Record concrete decisions such as prices, quotas, dates, roles, and fallbacks in acceptance
|
|
71
|
+
criteria rather than leaving them as assumptions in code or a pull request.
|
|
72
|
+
- Propose changes to existing requirements whose meaning moved; adding a new requirement is
|
|
73
|
+
not enough if it leaves an old one false.
|
|
74
|
+
- Reconcile again before finishing: every shipped behaviour maps to a current requirement,
|
|
75
|
+
every changed promise is recorded, and every focused requirement has marked evidence or
|
|
76
|
+
remains an explicit gap. Never let one broad marker imply coverage of untested clauses.
|
|
77
|
+
|
|
78
|
+
**Prevent contradictions before they enter the catalog.** Before proposing a new or changed
|
|
79
|
+
promise, compare its intended acceptance criteria with related active and draft requirements
|
|
80
|
+
and read their history. Ask whether both promises can be true at the same time for the same
|
|
81
|
+
user, state, and conditions. If not, do not add another requirement.
|
|
82
|
+
|
|
83
|
+
A failing test bound to an existing requirement, or an implementation that only works by
|
|
84
|
+
changing one, is a contradiction signal — not permission to weaken the test or write a second
|
|
85
|
+
requirement around it. Identify which promise matches product intent, then use
|
|
86
|
+
`fp propose-change` (or `--removal`) with the source and reason to put that decision before a
|
|
87
|
+
human. If the intent is unclear, stop and ask rather than making the catalog say both things.
|
|
88
|
+
|
|
55
89
|
### 2. Add the fp:<slug> marker to tests
|
|
56
90
|
|
|
57
91
|
Place a comment immediately before the test to bind it to a requirement:
|
|
@@ -106,31 +140,82 @@ fp report --junit junit.xml \
|
|
|
106
140
|
- `--surface` is the default; markers can override with `@suffix`
|
|
107
141
|
- `--base-dir DIR` sets where relative paths resolve from
|
|
108
142
|
|
|
109
|
-
### 5.
|
|
143
|
+
### 5. Write and propose decision-ready requirements
|
|
144
|
+
|
|
145
|
+
A requirement is a product decision a reviewer can approve, not a design task for an
|
|
146
|
+
engineer to carry out. It should sound like a capable teammate wrote it: natural, direct,
|
|
147
|
+
and specific enough to protect an outcome without dictating an accidental solution.
|
|
148
|
+
|
|
149
|
+
Treat the catalog as the current record of shared understanding, not a finished specification
|
|
150
|
+
handed from a product owner to implementers. Requirements are discovered and refined through
|
|
151
|
+
ongoing conversation among customers, domain experts, and builders. The human product owner
|
|
152
|
+
makes the final value and priority decisions; agents participate by exposing gaps,
|
|
153
|
+
inconsistencies, alternatives, and useful splits. Prefer customer outcomes over devotion to a
|
|
154
|
+
feature list.
|
|
155
|
+
|
|
156
|
+
Before drafting:
|
|
157
|
+
|
|
158
|
+
- Learn from the request, related requirements and history, support reports, research, and
|
|
159
|
+
observed user behaviour when available. Look for what people attempt, where they abandon a
|
|
160
|
+
task, what they actually use, and what experiments reveal. That evidence may expose a
|
|
161
|
+
different need from the first solution they suggested.
|
|
162
|
+
- Separate facts and decided policy from open questions. Never turn a missing decision into
|
|
163
|
+
a plausible-sounding price, quota, timeout, count, date, role, or fallback. Ask when the
|
|
164
|
+
answer changes the product promise.
|
|
165
|
+
- Identify the actor, the situation that triggers the need, the valuable outcome, and material
|
|
166
|
+
boundaries such as permissions, failures, and affected surfaces.
|
|
167
|
+
- Keep one independently failable promise per requirement. Use a parent and focused children
|
|
168
|
+
when a capability contains several decisions.
|
|
169
|
+
|
|
170
|
+
Write each field for a reviewer, not for the implementation:
|
|
171
|
+
|
|
172
|
+
- **Title:** one clear product promise in present tense. Name the capability or outcome, not
|
|
173
|
+
the project (`Dispatchers see current shipment status`, not `Implement status polling`).
|
|
174
|
+
- **Why:** one or two natural sentences saying who needs this, the problem or risk, and the
|
|
175
|
+
outcome worth protecting. Do not repeat the title, advertise the feature, or explain how
|
|
176
|
+
it will be built. Delete AI filler such as “ensure a seamless, robust experience.”
|
|
177
|
+
- **Acceptance:** observable behaviour that lets someone decide whether the promise is true.
|
|
178
|
+
State the relevant trigger or situation, the result, and material boundaries. Include
|
|
179
|
+
failure behaviour, permissions, or cross-surface consistency when those affect the promise.
|
|
180
|
+
- **Examples:** use concrete examples or Given/When/Then cases when they clarify a rule or an
|
|
181
|
+
edge. Examples and executable tests ground and double-check the specification; they do not
|
|
182
|
+
define every case or replace the general rule and continued conversation.
|
|
183
|
+
- **Numbers and policy:** include a number only when the source made it a real product decision
|
|
184
|
+
or an external constraint requires it. Do not invent implementation defaults and present
|
|
185
|
+
them as scope.
|
|
186
|
+
- **Implementation detail:** omit components, data models, algorithms, endpoints, polling
|
|
187
|
+
intervals, and UI controls unless that interface or constraint is itself user-visible policy.
|
|
188
|
+
|
|
189
|
+
A good requirement is concise, not merely short. Keep every sentence that changes a decision;
|
|
190
|
+
remove everything that only sounds thorough.
|
|
191
|
+
|
|
192
|
+
- ✅ **Title:** `Dispatchers see current shipment status`
|
|
193
|
+
**Why:** `Dispatchers need to spot delayed shipments before they miss a delivery window.`
|
|
194
|
+
**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.`
|
|
195
|
+
- ❌ **Title:** `Implement Redis-backed 30-second status polling`
|
|
196
|
+
**Why:** `This important feature ensures a seamless, robust experience.`
|
|
197
|
+
**Acceptance:** `Use a background worker and refresh every 30 seconds.`
|
|
198
|
+
This invents policy, prescribes a design, and never states the outcome.
|
|
199
|
+
|
|
200
|
+
Before saving, read the draft as a manager deciding scope: Is the value clear? Can the result
|
|
201
|
+
be observed? Are real product decisions distinguished from guesses? Could every clause fail
|
|
202
|
+
independently and, if so, should it be split? Does every sentence earn its place? Revise or ask
|
|
203
|
+
before creating the record if the answer is no.
|
|
204
|
+
|
|
205
|
+
Then create the proposal as a draft:
|
|
110
206
|
|
|
111
207
|
```bash
|
|
112
208
|
fp propose --project stowzilla \
|
|
113
|
-
--slug
|
|
114
|
-
--name "
|
|
115
|
-
--why "
|
|
116
|
-
--required api,
|
|
117
|
-
--acceptance "
|
|
209
|
+
--slug current_shipment_status \
|
|
210
|
+
--name "Dispatchers see current shipment status" \
|
|
211
|
+
--why "Dispatchers need to spot delayed shipments before they miss a delivery window" \
|
|
212
|
+
--required api,web \
|
|
213
|
+
--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" \
|
|
214
|
+
--source-type fizzy \
|
|
215
|
+
--source-url https://app.fizzy.do/123/cards/456
|
|
118
216
|
```
|
|
119
217
|
|
|
120
|
-
**
|
|
121
|
-
requirement should grasp in one read why the product needs it — the user problem it
|
|
122
|
-
solves or the outcome it protects. State that and stop. Longer is fine when it earns
|
|
123
|
-
its length; wordy is not. Avoid two things: filler that says nothing specific, and
|
|
124
|
-
describing *how* the feature works instead of *why* anyone wants it.
|
|
125
|
-
|
|
126
|
-
- ✅ `--why "Staff scan the label to find a container; without a QR they key the ID by hand and mis-type it."`
|
|
127
|
-
- ❌ `--why "Ensures a high-quality experience across all surfaces."` (filler — says nothing specific)
|
|
128
|
-
- ❌ `--why "The label renders a QR image encoding the container ID at print time."` (how it works, not why anyone needs it)
|
|
129
|
-
|
|
130
|
-
Same rule for `--acceptance`: the observable condition that proves it's done, not a
|
|
131
|
-
restatement of the title.
|
|
132
|
-
|
|
133
|
-
**Agents always create requirements as draft.** A human must activate in the web app.
|
|
218
|
+
**Agents always create requirements as draft.** A human must activate them in the web app.
|
|
134
219
|
|
|
135
220
|
### 6. Propose a change to an existing requirement
|
|
136
221
|
|
|
@@ -158,8 +243,9 @@ Rules:
|
|
|
158
243
|
|
|
159
244
|
- Always pass `--reason` and, where one exists, `--source-type`/`--source-url`. The whole
|
|
160
245
|
point of a proposal is that the *why* is recorded next to the *what*.
|
|
161
|
-
- Agents
|
|
162
|
-
|
|
246
|
+
- Agents may correct `--name` (the title) or `--slug` through a proposal. Existing
|
|
247
|
+
evidence follows a proposed slug, but every `fp:<slug>` marker still needs the same
|
|
248
|
+
correction. Agents still cannot remove a required surface.
|
|
163
249
|
- Agents cannot approve or reject. Only humans review.
|
|
164
250
|
- One open proposal per requirement — review the current one before proposing another.
|
|
165
251
|
|
|
@@ -247,6 +333,7 @@ fp init --project <slug> --surface <surface>
|
|
|
247
333
|
| Propose a change | `fp propose-change <slug> --project X --why "..." --reason "..."` |
|
|
248
334
|
| Propose a deletion | `fp propose-change <slug> --project X --removal --reason "..."` |
|
|
249
335
|
| View change history | `fp history <slug> --project X` |
|
|
336
|
+
| View work-item activity | `fp changes --project X --source-url URL` |
|
|
250
337
|
| Enforce parity | `fp check --project X` |
|
|
251
338
|
| Gate on this run | `fp check --project X --surface Y --junit file.xml` |
|
|
252
339
|
| View matrix | `fp matrix --project X` |
|