featureparity 0.0.27 → 0.0.28
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 +3 -0
- data/lib/fp/client.rb +53 -2
- data/lib/fp/commands/analyze.rb +17 -0
- data/lib/fp/commands/base.rb +2 -0
- data/lib/fp/commands/comments.rb +146 -0
- data/lib/fp/commands/help.rb +28 -0
- data/lib/fp/commands/history.rb +12 -1
- data/lib/fp/commands/init.rb +9 -0
- data/lib/fp/commands/projects.rb +5 -1
- data/lib/fp/commands/propose.rb +34 -1
- data/lib/fp/commands/propose_change.rb +6 -6
- data/lib/fp/commands/report.rb +17 -1
- data/lib/fp/commands/show.rb +14 -3
- data/lib/fp/commands/spot_check.rb +458 -0
- data/lib/fp/commands/tutorial.rb +95 -0
- data/lib/fp/commands/whoami.rb +2 -0
- data/lib/fp/commands.rb +3 -0
- data/lib/fp/config.rb +12 -0
- data/lib/fp/junit.rb +14 -4
- data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
- data/lib/fp/tutorial/hexhaven/README.md +29 -0
- data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
- data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
- data/lib/fp/version.rb +1 -1
- data/skills/fp/references/cli.md +73 -0
- metadata +11 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dd6b0a540d21e123a2ceb43cb9f23005c218319866ff7e079b951d6288199bb1
|
|
4
|
+
data.tar.gz: 6e0d61e3a3d9e6f590c6e978b134736877b5abc8da2a9a80332ff6cd203dbf58
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fe1d8110555996bc72c885d502139e6f18dbef1d2ee8b8623eaf48c74dce6c40b33f46ffe4b42f09c9f92570380abab9b4dff77df3520d300ec6f43f35214890
|
|
7
|
+
data.tar.gz: 1f95d64e43e62a63411bd8cc665248777c3acdc58795e16552d4b5f671d3a1fb89a774bbf066e5f65f55bba7b716cc5f2fa78d51ea2b1a5afdc1fcd547acfa2d
|
data/lib/fp/cli.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Fp
|
|
|
7
7
|
class CLI
|
|
8
8
|
COMMANDS = {
|
|
9
9
|
'init' => Commands::Init,
|
|
10
|
+
'tutorial' => Commands::Tutorial,
|
|
10
11
|
'projects' => Commands::Projects,
|
|
11
12
|
'surfaces' => Commands::Surfaces,
|
|
12
13
|
'showroom' => Commands::Showroom,
|
|
@@ -15,12 +16,14 @@ module Fp
|
|
|
15
16
|
'propose' => Commands::Propose,
|
|
16
17
|
'propose-change' => Commands::ProposeChange,
|
|
17
18
|
'comment' => Commands::Comment,
|
|
19
|
+
'comments' => Commands::Comments,
|
|
18
20
|
'history' => Commands::History,
|
|
19
21
|
'analyze' => Commands::Analyze,
|
|
20
22
|
'changes' => Commands::Changes,
|
|
21
23
|
'report' => Commands::Report,
|
|
22
24
|
'ci-report' => Commands::CiReport,
|
|
23
25
|
'changed-tests' => Commands::ChangedTests,
|
|
26
|
+
'spot-check' => Commands::SpotCheck,
|
|
24
27
|
'check' => Commands::Check,
|
|
25
28
|
'matrix' => Commands::Matrix,
|
|
26
29
|
'export' => Commands::Export,
|
data/lib/fp/client.rb
CHANGED
|
@@ -208,6 +208,12 @@ module Fp
|
|
|
208
208
|
post("/api/requirements/#{requirement_id}/analyze", body)
|
|
209
209
|
end
|
|
210
210
|
|
|
211
|
+
# POST /requirements/:id/test_review — Jev's read of whether these tests check what
|
|
212
|
+
# the requirement promises (`fp spot-check --jev`). Paid plans only (402 otherwise).
|
|
213
|
+
def review_spot_check_tests(requirement_id, tests)
|
|
214
|
+
post("/api/requirements/#{requirement_id}/test_review", { tests: tests })
|
|
215
|
+
end
|
|
216
|
+
|
|
211
217
|
# GET /requirements/:id/changes
|
|
212
218
|
#
|
|
213
219
|
# A requirement's change history: what it was, what changed, who changed it, and
|
|
@@ -241,6 +247,22 @@ module Fp
|
|
|
241
247
|
post("/api/requirements/#{requirement_id}/changes/#{change_id}/comment", params)
|
|
242
248
|
end
|
|
243
249
|
|
|
250
|
+
# GET /requirements/:id/comments
|
|
251
|
+
#
|
|
252
|
+
# The team's comments on a requirement, oldest first, plus who can be tagged:
|
|
253
|
+
# `mentionable` (people) and `mentionable_agents` (Fizzy #1494).
|
|
254
|
+
def list_requirement_comments(requirement_id)
|
|
255
|
+
get("/api/requirements/#{requirement_id}/comments")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# POST /requirements/:id/comments
|
|
259
|
+
#
|
|
260
|
+
# Comment on a requirement. `mentions` are emails, `agent_mentions` agent key ids;
|
|
261
|
+
# tagged people are emailed and tagged agents fire the project's webhooks.
|
|
262
|
+
def comment_on_requirement(requirement_id, params)
|
|
263
|
+
post("/api/requirements/#{requirement_id}/comments", params)
|
|
264
|
+
end
|
|
265
|
+
|
|
244
266
|
# DELETE /requirements/:id/changes/:change_id
|
|
245
267
|
#
|
|
246
268
|
# Withdraw an open proposal. Only the proposal's author can withdraw it; the
|
|
@@ -513,7 +535,7 @@ module Fp
|
|
|
513
535
|
http.read_timeout = 30
|
|
514
536
|
|
|
515
537
|
response = http.request(request)
|
|
516
|
-
parse_response(response)
|
|
538
|
+
parse_response(response, uri)
|
|
517
539
|
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
518
540
|
{ ok: false, error: "Request timed out: #{e.message}", status: 0 }
|
|
519
541
|
rescue SocketError, Errno::ECONNREFUSED => e
|
|
@@ -522,13 +544,20 @@ module Fp
|
|
|
522
544
|
{ ok: false, error: "Request failed: #{e.message}", status: 0 }
|
|
523
545
|
end
|
|
524
546
|
|
|
525
|
-
def parse_response(response)
|
|
547
|
+
def parse_response(response, uri = nil)
|
|
526
548
|
body = response.body
|
|
527
549
|
status = response.code.to_i
|
|
528
550
|
|
|
529
551
|
# Try to parse as JSON
|
|
530
552
|
data = JSON.parse(body) rescue nil
|
|
531
553
|
|
|
554
|
+
# A success that isn't JSON is almost always the web app answering in the API's
|
|
555
|
+
# place — FP_API_URL set to the site rather than its api host — and every command
|
|
556
|
+
# used to crash on it with "undefined method '[]' for nil" (Fizzy #1446).
|
|
557
|
+
if status >= 200 && status < 300 && data.nil? && !body.to_s.strip.empty?
|
|
558
|
+
return { ok: false, status: status, error: not_the_api_message(uri) }
|
|
559
|
+
end
|
|
560
|
+
|
|
532
561
|
if status >= 200 && status < 300
|
|
533
562
|
{ ok: true, data: data, status: status }
|
|
534
563
|
else
|
|
@@ -544,5 +573,27 @@ module Fp
|
|
|
544
573
|
result
|
|
545
574
|
end
|
|
546
575
|
end
|
|
576
|
+
|
|
577
|
+
def not_the_api_message(uri)
|
|
578
|
+
origin = uri ? "#{uri.scheme}://#{uri.host}" : @base_url
|
|
579
|
+
message = +"#{origin} answered with a web page, not the FeatureParity API. " \
|
|
580
|
+
'Point fp at the API host instead (check it with `fp whoami`).'
|
|
581
|
+
suggestion = api_host_for(uri&.host)
|
|
582
|
+
message << " Did you mean https://#{suggestion}? (export FP_API_URL=https://#{suggestion})" if suggestion
|
|
583
|
+
message
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
# The API host that sits beside a web-app host: featureparity.dev → api.featureparity.dev,
|
|
587
|
+
# an ephemeral <env>.dev.featureparity.dev → api-<env>.dev.featureparity.dev.
|
|
588
|
+
def api_host_for(host)
|
|
589
|
+
return nil if host.nil? || host.start_with?('api.', 'api-')
|
|
590
|
+
return nil unless host.end_with?('featureparity.dev')
|
|
591
|
+
|
|
592
|
+
labels = host.split('.')
|
|
593
|
+
return "api.#{host}" if labels.length <= 2
|
|
594
|
+
return "api.#{host}" if labels.first == 'dev' || labels.first == 'staging'
|
|
595
|
+
|
|
596
|
+
"api-#{host}"
|
|
597
|
+
end
|
|
547
598
|
end
|
|
548
599
|
end
|
data/lib/fp/commands/analyze.rb
CHANGED
|
@@ -153,6 +153,7 @@ module Fp
|
|
|
153
153
|
'duplicate_of' => issues.find { |i| i['type'] == 'duplicate' }&.dig('duplicate_of'),
|
|
154
154
|
'conflicts' => (analysis && analysis['conflicts']) || [],
|
|
155
155
|
'suggestions' => (analysis && analysis['suggestions']) || [],
|
|
156
|
+
'warrants_requirement' => analysis && analysis['warrants_requirement'],
|
|
156
157
|
'error' => error
|
|
157
158
|
}
|
|
158
159
|
end
|
|
@@ -199,6 +200,8 @@ module Fp
|
|
|
199
200
|
end
|
|
200
201
|
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
201
202
|
puts " issues: #{entry['issues'].map { |i| i['type'] }.join(', ')}" unless entry['issues'].empty?
|
|
203
|
+
warrants = warrants_line(entry['warrants_requirement'])
|
|
204
|
+
puts " #{warrants}" if warrants && entry['warrants_requirement']['judgment'] == false
|
|
202
205
|
puts " error: #{entry['error']}" if entry['error']
|
|
203
206
|
end
|
|
204
207
|
|
|
@@ -206,6 +209,8 @@ module Fp
|
|
|
206
209
|
puts "#{entry['slug']} — #{entry['title']}"
|
|
207
210
|
puts " verdict: #{entry['verdict'] || '(none)'} suggested: #{ACTIONS[entry['action']]}"
|
|
208
211
|
puts " superseded by: #{entry['duplicate_of']}" if entry['duplicate_of']
|
|
212
|
+
warrants = warrants_line(entry['warrants_requirement'])
|
|
213
|
+
puts " #{warrants}" if warrants
|
|
209
214
|
puts " fails: #{entry['failed_checks'].join(', ')}" unless entry['failed_checks'].empty?
|
|
210
215
|
entry['issues'].each { |i| puts " issue: #{i['type']}" }
|
|
211
216
|
entry['conflicts'].each do |c|
|
|
@@ -215,6 +220,18 @@ module Fp
|
|
|
215
220
|
puts " error: #{entry['error']}" if entry['error']
|
|
216
221
|
end
|
|
217
222
|
|
|
223
|
+
# The analyzer's yes/no on whether the concept deserves to be a requirement at all.
|
|
224
|
+
# A "no" carries its reason so a human can decide to withdraw it — the CLI only
|
|
225
|
+
# reports it; nothing is withdrawn or proposed on the strength of the judgment.
|
|
226
|
+
def warrants_line(warrants)
|
|
227
|
+
return nil unless warrants.is_a?(Hash) && [true, false].include?(warrants['judgment'])
|
|
228
|
+
|
|
229
|
+
line = "warrants a requirement: #{warrants['judgment'] ? 'yes' : 'no'}"
|
|
230
|
+
line += " (confidence #{(warrants['confidence'].to_f * 100).round}%)" unless warrants['confidence'].nil?
|
|
231
|
+
line += " — #{warrants['reason']}" if !warrants['judgment'] && !warrants['reason'].to_s.empty?
|
|
232
|
+
line
|
|
233
|
+
end
|
|
234
|
+
|
|
218
235
|
def progress(message)
|
|
219
236
|
warn message unless output.json?
|
|
220
237
|
end
|
data/lib/fp/commands/base.rb
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# `fp comments <slug>` — the team's conversation on a requirement (Fizzy #1494).
|
|
6
|
+
#
|
|
7
|
+
# Without --body it prints the comments, oldest first. With --body it posts one, and
|
|
8
|
+
# --tag names who to tag: a teammate by email or name, or one of the team's agents by
|
|
9
|
+
# name or key name. Tagged people are emailed; each tagged agent fires the project's
|
|
10
|
+
# `requirement.agent_mentioned` webhooks, so whatever runs that agent can pick it up.
|
|
11
|
+
#
|
|
12
|
+
# Not to be confused with `fp comment`, which threads a comment onto a requirement's
|
|
13
|
+
# open change *proposal*.
|
|
14
|
+
class Comments < Base
|
|
15
|
+
KNOWN_FLAGS = { project: :string, body: :string, tag: :string }.freeze
|
|
16
|
+
|
|
17
|
+
help_summary "Read or post the team's comments on a requirement, tagging people or agents"
|
|
18
|
+
help_usage 'fp comments <slug> [--body "..." [--tag <who>,<who>]] --project <slug>'
|
|
19
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
20
|
+
help_flag :body, 'Post this comment (omit to list the comments)'
|
|
21
|
+
help_flag :tag, 'Comma-separated people (email or name) or agents (name or key name) to tag'
|
|
22
|
+
help_example 'fp comments print_qr --project stowzilla'
|
|
23
|
+
help_example 'fp comments print_qr --body "The redesign dropped this screen — retire it?" --tag andy@example.com'
|
|
24
|
+
|
|
25
|
+
def run(args)
|
|
26
|
+
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
27
|
+
require_project!(opts)
|
|
28
|
+
|
|
29
|
+
slug = positional.first
|
|
30
|
+
unless slug
|
|
31
|
+
output.error('Requirement slug is required. Usage: fp comments <slug> [--body "..."] [--project <project>]')
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
project_id = resolve_project_id(opts[:project])
|
|
36
|
+
requirement = find_requirement!(project_id, slug)
|
|
37
|
+
listing = fetch!(client.list_requirement_comments(requirement['id']))
|
|
38
|
+
|
|
39
|
+
if opts[:body].nil?
|
|
40
|
+
print_comments(listing, slug)
|
|
41
|
+
else
|
|
42
|
+
post_comment(requirement, listing, opts, slug)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def post_comment(requirement, listing, opts, slug)
|
|
49
|
+
body = opts[:body].to_s
|
|
50
|
+
if body.strip.empty?
|
|
51
|
+
output.error('A comment needs some text. Pass --body "...".')
|
|
52
|
+
exit 1
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
people, agents = resolve_tags(opts[:tag], listing)
|
|
56
|
+
data = fetch!(client.comment_on_requirement(requirement['id'],
|
|
57
|
+
body: body,
|
|
58
|
+
mentions: people.map { |p| p['email'] },
|
|
59
|
+
agent_mentions: agents.map { |a| a['id'] }))
|
|
60
|
+
comment = data['comment'] || {}
|
|
61
|
+
|
|
62
|
+
output.success(data) do
|
|
63
|
+
puts "Commented on '#{slug}'."
|
|
64
|
+
puts
|
|
65
|
+
print_comment(comment)
|
|
66
|
+
tagged_people = Array(comment['mentions']).map { |m| m['name'] }
|
|
67
|
+
tagged_agents = Array(comment['agent_mentions']).map { |m| m['name'] }
|
|
68
|
+
puts "#{tagged_people.join(', ')} will get an email." unless tagged_people.empty?
|
|
69
|
+
puts "#{tagged_agents.join(', ')} will be notified through the project's webhooks." unless tagged_agents.empty?
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Each --tag matches exactly one person (email or name) or agent (name or key
|
|
74
|
+
# name), case-insensitively. Unknown or ambiguous tags stop the command rather
|
|
75
|
+
# than post a comment that silently tags nobody.
|
|
76
|
+
def resolve_tags(raw, listing)
|
|
77
|
+
people = []
|
|
78
|
+
agents = []
|
|
79
|
+
raw.to_s.split(',').map(&:strip).reject(&:empty?).each do |tag|
|
|
80
|
+
wanted = tag.delete_prefix('@').downcase
|
|
81
|
+
person_hits = Array(listing['mentionable']).select do |p|
|
|
82
|
+
[p['email'], p['name']].compact.map(&:downcase).include?(wanted)
|
|
83
|
+
end
|
|
84
|
+
agent_hits = Array(listing['mentionable_agents']).select do |a|
|
|
85
|
+
[a['name'], a['key_name']].compact.map(&:downcase).include?(wanted)
|
|
86
|
+
end
|
|
87
|
+
hits = person_hits + agent_hits
|
|
88
|
+
if hits.empty?
|
|
89
|
+
output.error("Nobody on this project's team matches '#{tag}'. Run `fp comments <slug>` to see who can be tagged.")
|
|
90
|
+
exit 1
|
|
91
|
+
elsif hits.size > 1
|
|
92
|
+
output.error("'#{tag}' matches more than one person or agent; use an email or key name instead.")
|
|
93
|
+
exit 1
|
|
94
|
+
end
|
|
95
|
+
person_hits.empty? ? agents << agent_hits.first : people << person_hits.first
|
|
96
|
+
end
|
|
97
|
+
[people, agents]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def print_comments(listing, slug)
|
|
101
|
+
comments = Array(listing['comments'])
|
|
102
|
+
output.success(listing) do
|
|
103
|
+
puts "Comments on '#{slug}'"
|
|
104
|
+
puts
|
|
105
|
+
if comments.empty?
|
|
106
|
+
puts ' (no comments yet)'
|
|
107
|
+
else
|
|
108
|
+
comments.each { |comment| print_comment(comment) }
|
|
109
|
+
end
|
|
110
|
+
print_taggable(listing)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def print_comment(comment)
|
|
115
|
+
kind = comment['actor_kind'] == 'agent' ? ' (agent)' : ''
|
|
116
|
+
puts "* #{comment['actor_name'] || comment['actor']}#{kind} at #{comment['recorded_at']}"
|
|
117
|
+
comment['body'].to_s.each_line { |line| puts " > #{line.chomp}" }
|
|
118
|
+
tags = Array(comment['mentions']) + Array(comment['agent_mentions'])
|
|
119
|
+
puts " tagged: #{tags.map { |t| t['name'] }.join(', ')}" unless tags.empty?
|
|
120
|
+
puts
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def print_taggable(listing)
|
|
124
|
+
people = Array(listing['mentionable']).map { |p| "#{p['name']} <#{p['email']}>" }
|
|
125
|
+
agents = Array(listing['mentionable_agents']).map { |a| "#{a['name']} (#{a['key_name']}, #{a['owner_name']}'s agent)" }
|
|
126
|
+
return if people.empty? && agents.empty?
|
|
127
|
+
|
|
128
|
+
puts 'Can be tagged with --tag:'
|
|
129
|
+
people.each { |p| puts " #{p}" }
|
|
130
|
+
agents.each { |a| puts " #{a}" }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def find_requirement!(project_id, slug)
|
|
134
|
+
fetch!(client.find_requirement_by_slug(project_id, slug))['requirement']
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def fetch!(result)
|
|
138
|
+
unless result[:ok]
|
|
139
|
+
output.error(result[:error], status: result[:status])
|
|
140
|
+
exit 1
|
|
141
|
+
end
|
|
142
|
+
result[:data]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -39,6 +39,7 @@ module Fp
|
|
|
39
39
|
|
|
40
40
|
COMMANDS
|
|
41
41
|
init --project <slug> Onboard this repo: config, CI gate, agent docs
|
|
42
|
+
tutorial Write Hexhaven, a sample repo to practise on
|
|
42
43
|
projects List projects you have access to
|
|
43
44
|
projects show <slug> View a project and its surfaces
|
|
44
45
|
projects create <slug> --name ... Create a project (owned by your key's owner)
|
|
@@ -62,6 +63,8 @@ module Fp
|
|
|
62
63
|
propose-change <slug> --no-parent Propose un-nesting a child to top level
|
|
63
64
|
propose-change <slug> --withdraw Withdraw your own open proposal (to amend it)
|
|
64
65
|
comment <slug> --body "..." Comment on a requirement's open change proposal
|
|
66
|
+
comments <slug> Read the team's comments on a requirement
|
|
67
|
+
comments <slug> --body "..." [--tag who,...] Comment on a requirement, tagging people or agents
|
|
65
68
|
history <slug> --project <slug> Show a requirement's change history + diffs
|
|
66
69
|
analyze [<slug>] --project <slug> Triage requirement quality (read-only; never proposes)
|
|
67
70
|
changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
|
|
@@ -76,6 +79,7 @@ module Fp
|
|
|
76
79
|
ci-report --junit <file> --pr URL Ingest a PR's CI results as pr_passing — CI only
|
|
77
80
|
changed-tests List unresolved bound-test revision findings
|
|
78
81
|
changed-tests assess <id> ... Explain whether an exact revision still proves its requirement
|
|
82
|
+
spot-check [<slug>...] Check requirements' tests prove them (random, --count, --all, --test)
|
|
79
83
|
check Enforce parity; exits non-zero on violations
|
|
80
84
|
matrix --project <slug> Show the parity matrix
|
|
81
85
|
export --project <slug> Export all requirements as a Markdown document
|
|
@@ -159,6 +163,30 @@ module Fp
|
|
|
159
163
|
A stale or later candidate needs a new assessment. A PR candidate becomes accepted only
|
|
160
164
|
when a default-branch CI report later observes the same fingerprint.
|
|
161
165
|
|
|
166
|
+
SPOT CHECKS
|
|
167
|
+
A green receipt says a marked test ran and passed, not that it checks what the
|
|
168
|
+
requirement promises. `fp spot-check` picks an active requirement with evidence at
|
|
169
|
+
random, prints its acceptance beside the source of every locally marked test, and
|
|
170
|
+
gives the agent running it a brief: match each clause to an assertion, then break
|
|
171
|
+
the product code and confirm a test fails. Run it on a schedule or by hand.
|
|
172
|
+
|
|
173
|
+
fp spot-check # a random pick; prints the seed to repeat it
|
|
174
|
+
fp spot-check --count 5 --surface cli --json
|
|
175
|
+
fp spot-check print_container_qr print_label # chosen requirements
|
|
176
|
+
fp spot-check --all # every active requirement with evidence
|
|
177
|
+
fp spot-check --test test/qr_test.rb:42 # the test there, and its requirement
|
|
178
|
+
fp spot-check --count 3 --prompt | claude -p # hand it straight to an agent
|
|
179
|
+
fp spot-check --jev # add Jev's first read (paid plans)
|
|
180
|
+
|
|
181
|
+
`--prompt` prints the spot check as instructions for an agent, with what to report
|
|
182
|
+
back. `--jev` asks Jev, before the agent starts, whether each requirement's tests
|
|
183
|
+
appear to cover its acceptance, and which gap is likeliest. It's a hint for aiming
|
|
184
|
+
the probe, not a verdict. Jev is for paid plans: without one the spot check runs
|
|
185
|
+
without it.
|
|
186
|
+
|
|
187
|
+
It records nothing and never edits a test or a requirement. A test that stays
|
|
188
|
+
green when the promise is broken goes to a human.
|
|
189
|
+
|
|
162
190
|
AUTHENTICATION
|
|
163
191
|
Set FP_API_KEY environment variable, or use a profile:
|
|
164
192
|
|
data/lib/fp/commands/history.rb
CHANGED
|
@@ -31,7 +31,9 @@ module Fp
|
|
|
31
31
|
'removal_proposed' => 'deletion proposed',
|
|
32
32
|
'change_approved' => 'change approved',
|
|
33
33
|
'change_rejected' => 'change rejected',
|
|
34
|
-
'decision_recorded' => 'decision recorded'
|
|
34
|
+
'decision_recorded' => 'decision recorded',
|
|
35
|
+
'change_commented' => 'commented on the proposal',
|
|
36
|
+
'commented' => 'commented'
|
|
35
37
|
}.freeze
|
|
36
38
|
|
|
37
39
|
# A member's own stance on a requirement that already has a status (Fizzy #1478),
|
|
@@ -110,6 +112,7 @@ module Fp
|
|
|
110
112
|
puts "* #{label} — #{actor} (#{kind}) at #{format_time(change['recorded_at'])}"
|
|
111
113
|
puts " #{change['reason']}" if present?(change['reason'])
|
|
112
114
|
puts " note: #{change['note']}" if change['type'] == 'decision_recorded' && present?(change['note'])
|
|
115
|
+
print_comment(change) if %w[commented change_commented].include?(change['type'])
|
|
113
116
|
print_source(change['source'])
|
|
114
117
|
print_diff(change['changes'])
|
|
115
118
|
print_evidence_diff(change['evidence_diff'])
|
|
@@ -117,6 +120,14 @@ module Fp
|
|
|
117
120
|
puts
|
|
118
121
|
end
|
|
119
122
|
|
|
123
|
+
# The team's comments (Fizzy #1494) and comments on a proposal, so an agent reading
|
|
124
|
+
# the history sees the discussion, not just that one happened.
|
|
125
|
+
def print_comment(change)
|
|
126
|
+
change['body'].to_s.each_line { |line| puts " > #{line.chomp}" } if present?(change['body'])
|
|
127
|
+
mentions = Array(change['mentions'])
|
|
128
|
+
puts " tagged: #{mentions.join(', ')}" unless mentions.empty?
|
|
129
|
+
end
|
|
130
|
+
|
|
120
131
|
def print_source(source)
|
|
121
132
|
return if source.nil? || source.empty?
|
|
122
133
|
|
data/lib/fp/commands/init.rb
CHANGED
|
@@ -88,6 +88,15 @@ module Fp
|
|
|
88
88
|
report(project_slug, root, written, skipped, opts)
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# The same agent brief `fp init` writes, for another command to put in a repo it
|
|
92
|
+
# creates (`fp tutorial`'s Hexhaven), so the two can never drift apart. Appends to
|
|
93
|
+
# an existing AGENTS.md/CLAUDE.md the way init does. Returns the paths written.
|
|
94
|
+
def self.write_agent_docs!(root, project_slug)
|
|
95
|
+
written = []
|
|
96
|
+
new.send(:write_agent_docs, root, project_slug, { dir: root }, written, [])
|
|
97
|
+
written.map(&:first)
|
|
98
|
+
end
|
|
99
|
+
|
|
91
100
|
private
|
|
92
101
|
|
|
93
102
|
def write_project_config(root, project_slug, level, opts, written, skipped)
|
data/lib/fp/commands/projects.rb
CHANGED
|
@@ -87,7 +87,11 @@ module Fp
|
|
|
87
87
|
output.success(result[:data]) do
|
|
88
88
|
if projects.empty?
|
|
89
89
|
puts 'No projects found.'
|
|
90
|
-
|
|
90
|
+
if (hint = config&.environment_mismatch_hint)
|
|
91
|
+
puts hint
|
|
92
|
+
else
|
|
93
|
+
puts 'Create one with: fp projects create <slug> --name "<Name>"'
|
|
94
|
+
end
|
|
91
95
|
else
|
|
92
96
|
headers = %w[SLUG NAME SURFACES]
|
|
93
97
|
rows = projects.map do |w|
|
data/lib/fp/commands/propose.rb
CHANGED
|
@@ -108,8 +108,10 @@ module Fp
|
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
requirement = result[:data]['requirement']
|
|
111
|
+
feedback = draft_feedback(requirement)
|
|
112
|
+
data = result[:data].merge('analysis' => feedback[:analysis], 'analysis_error' => feedback[:error]).compact
|
|
111
113
|
|
|
112
|
-
output.success(
|
|
114
|
+
output.success(data) do
|
|
113
115
|
puts "Created requirement '#{requirement['slug']}' as DRAFT."
|
|
114
116
|
puts
|
|
115
117
|
puts " Title: #{requirement['title']}"
|
|
@@ -125,11 +127,42 @@ module Fp
|
|
|
125
127
|
puts '⚠️ This requirement is a DRAFT.'
|
|
126
128
|
puts ' A human must activate it in the web app before it appears in the parity matrix.'
|
|
127
129
|
puts ' Agents cannot activate requirements.'
|
|
130
|
+
print_feedback(feedback, requirement['slug'])
|
|
128
131
|
end
|
|
129
132
|
end
|
|
130
133
|
|
|
131
134
|
private
|
|
132
135
|
|
|
136
|
+
# Quality feedback on the draft just created, as part of the same command, so an
|
|
137
|
+
# agent learns at once that its draft is weak or shouldn't exist and can fix or
|
|
138
|
+
# withdraw it before a human looks (requirement_analysis_runs_on_draft). It never
|
|
139
|
+
# undoes the draft: when analysis is unavailable the draft stands and we say so.
|
|
140
|
+
def draft_feedback(requirement)
|
|
141
|
+
result = client.analyze_requirement(requirement['id'])
|
|
142
|
+
return { analysis: result[:data]['analysis'] } if result[:ok] && result[:data].is_a?(Hash) && result[:data]['analysis']
|
|
143
|
+
|
|
144
|
+
{ error: result[:error] || 'no analysis came back' }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def print_feedback(feedback, slug)
|
|
148
|
+
puts
|
|
149
|
+
if feedback[:error]
|
|
150
|
+
puts "Quality feedback could not be produced: #{feedback[:error]}"
|
|
151
|
+
puts "The draft was still created. Try again later with `fp analyze #{slug}`."
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
analysis = feedback[:analysis]
|
|
156
|
+
puts "Quality feedback: #{analysis['verdict'] || 'no verdict'}"
|
|
157
|
+
Array(analysis['issues']).each do |issue|
|
|
158
|
+
puts " - #{issue['type'].to_s.tr('_', ' ')}"
|
|
159
|
+
end
|
|
160
|
+
Array(analysis['suggestions']).first(3).each { |suggestion| puts " → #{suggestion}" }
|
|
161
|
+
return unless %w[questionable needs_work].include?(analysis['verdict'])
|
|
162
|
+
|
|
163
|
+
puts "Revise it with `fp propose-change #{slug} ...`, or withdraw it if it shouldn't be a requirement."
|
|
164
|
+
end
|
|
165
|
+
|
|
133
166
|
# Parse a comma-separated --required-types list into a normalized array. Returns []
|
|
134
167
|
# when the flag is absent (meaning "any test counts / inherit project default").
|
|
135
168
|
#
|
|
@@ -23,16 +23,16 @@ module Fp
|
|
|
23
23
|
help_flag :parent, "Set the parent requirement's slug"
|
|
24
24
|
help_flag :no_parent, 'Propose un-nesting the requirement back to the top level (clears --parent)'
|
|
25
25
|
help_flag :slug, 'Proposed new slug (evidence follows; markers still need updating)'
|
|
26
|
-
help_flag :reason, 'What prompted this change
|
|
26
|
+
help_flag :reason, 'What prompted this change, in words (recorded on the change)'
|
|
27
27
|
help_flag :removal, 'Propose deleting the requirement instead of changing it'
|
|
28
28
|
help_flag :withdraw, 'Take back the open proposal you authored, restoring the requirement as it was'
|
|
29
|
-
help_flag :source_type, "
|
|
30
|
-
help_flag :source_url,
|
|
31
|
-
help_flag :source_context,
|
|
29
|
+
help_flag :source_type, "Where this proposal came from (recorded on the change; the requirement's origin is kept)"
|
|
30
|
+
help_flag :source_url, 'URL of the card/issue/thread that prompted this proposal'
|
|
31
|
+
help_flag :source_context, 'Free-form note about what prompted this proposal'
|
|
32
32
|
help_example 'fp propose-change print_qr --project stowzilla --acceptance "QR scans after 24h at -20C" --reason "Cold-chain rollout"'
|
|
33
33
|
help_example 'fp propose-change print_qr --removal --reason "Superseded by print_container_label"'
|
|
34
|
-
help_note "Applies the change and drops the requirement to draft for a human to review.
|
|
35
|
-
"
|
|
34
|
+
help_note "Applies the change and drops the requirement to draft for a human to review. --reason and\n" \
|
|
35
|
+
"--source-* record what prompted the proposal in its history; the requirement's own origin is unchanged."
|
|
36
36
|
|
|
37
37
|
KNOWN_FLAGS = {
|
|
38
38
|
project: :string, name: :string, why: :string, acceptance: :string,
|
data/lib/fp/commands/report.rb
CHANGED
|
@@ -208,7 +208,10 @@ module Fp
|
|
|
208
208
|
|
|
209
209
|
# Batch-upload evidence from a JUnit XML report.
|
|
210
210
|
def run_junit(opts)
|
|
211
|
-
|
|
211
|
+
# The report's own checkout says which project it is: discovering from the
|
|
212
|
+
# report's directory (and up) means `fp report --junit hexhaven/junit.xml` run
|
|
213
|
+
# from a parent folder reads hexhaven's .featureparity.yml, not the parent's.
|
|
214
|
+
require_project!(opts, ProjectConfig.discover(File.dirname(File.expand_path(opts[:junit].to_s))))
|
|
212
215
|
require_flag(opts, :repo)
|
|
213
216
|
# --surface is the default for markers that don't pin their own surface
|
|
214
217
|
# (e.g. `fp:slug@api,web`). It's only strictly required when at least one
|
|
@@ -244,6 +247,19 @@ module Fp
|
|
|
244
247
|
result = Fp::JUnit.bind_markers(testcases, base_dir: base_dir,
|
|
245
248
|
default_surface: opts[:surface],
|
|
246
249
|
default_test_type: opts[:test_type])
|
|
250
|
+
# Run from outside the checkout, paths in the report resolve from nowhere. When
|
|
251
|
+
# nothing binds from here and no --base-dir was given, try the report's own
|
|
252
|
+
# directory before giving up.
|
|
253
|
+
report_dir = File.dirname(File.expand_path(opts[:junit].to_s))
|
|
254
|
+
if result[:bindings].empty? && opts[:base_dir].nil? && report_dir != File.expand_path(base_dir)
|
|
255
|
+
retried = Fp::JUnit.bind_markers(testcases, base_dir: report_dir,
|
|
256
|
+
default_surface: opts[:surface],
|
|
257
|
+
default_test_type: opts[:test_type])
|
|
258
|
+
if retried[:bindings].any?
|
|
259
|
+
base_dir = report_dir
|
|
260
|
+
result = retried
|
|
261
|
+
end
|
|
262
|
+
end
|
|
247
263
|
bindings = result[:bindings]
|
|
248
264
|
unmatched = result[:unmatched]
|
|
249
265
|
missing_surface = result[:missing_surface] || []
|
data/lib/fp/commands/show.rb
CHANGED
|
@@ -59,7 +59,13 @@ module Fp
|
|
|
59
59
|
result = client.list_evidence(project_id, slug: slug)
|
|
60
60
|
return [] unless result[:ok]
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
# The server names the receipts CI has since spoken for; flag them on the rows so
|
|
63
|
+
# both the listing and --json say so, rather than a stale `present` reading as a
|
|
64
|
+
# second verdict beside CI's (Fizzy #1762).
|
|
65
|
+
superseded = Array(result[:data]['superseded_ids'])
|
|
66
|
+
(result[:data]['evidence'] || []).map do |e|
|
|
67
|
+
superseded.include?(e['id']) ? e.merge('superseded' => true) : e
|
|
68
|
+
end
|
|
63
69
|
end
|
|
64
70
|
|
|
65
71
|
def fetch_attachments(requirement_id)
|
|
@@ -156,8 +162,13 @@ module Fp
|
|
|
156
162
|
.group_by { |e| e['surface'] }
|
|
157
163
|
.each do |surface, records|
|
|
158
164
|
puts " #{surface}:"
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
# The receipts the matrix counts first; superseded ones after.
|
|
166
|
+
records.partition { |e| !e['superseded'] }.flatten(1).each do |e|
|
|
167
|
+
if e['superseded']
|
|
168
|
+
puts " Status: superseded by CI (was #{e['status']}; CI's latest run is what counts)"
|
|
169
|
+
else
|
|
170
|
+
puts " Status: #{e['status']}"
|
|
171
|
+
end
|
|
161
172
|
# A pull request's CI may have written over the merged verdict; show both
|
|
162
173
|
# when they differ so "failing" next to "PR: …" reads as the branch's
|
|
163
174
|
# result, not the default branch's.
|