featureparity 0.0.27 → 0.0.29
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 +14 -2
- data/lib/fp/client.rb +79 -2
- data/lib/fp/commands/analyze.rb +17 -0
- data/lib/fp/commands/base.rb +16 -0
- data/lib/fp/commands/check.rb +38 -4
- data/lib/fp/commands/ci_report.rb +2 -2
- data/lib/fp/commands/comment.rb +1 -1
- data/lib/fp/commands/comments.rb +146 -0
- data/lib/fp/commands/coverage.rb +149 -0
- data/lib/fp/commands/doctor.rb +228 -0
- data/lib/fp/commands/help.rb +41 -1
- data/lib/fp/commands/history.rb +16 -1
- data/lib/fp/commands/init.rb +181 -117
- data/lib/fp/commands/list.rb +58 -4
- data/lib/fp/commands/mark.rb +167 -0
- data/lib/fp/commands/projects.rb +6 -1
- data/lib/fp/commands/propose.rb +435 -53
- data/lib/fp/commands/propose_change.rb +13 -7
- data/lib/fp/commands/question.rb +137 -0
- data/lib/fp/commands/report.rb +40 -4
- data/lib/fp/commands/run.rb +147 -0
- data/lib/fp/commands/show.rb +41 -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 +8 -0
- data/lib/fp/config.rb +12 -0
- data/lib/fp/junit.rb +74 -11
- data/lib/fp/repo_detector.rb +488 -0
- data/lib/fp/requirement_lint.rb +105 -0
- data/lib/fp/rspec_json.rb +54 -0
- 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/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +244 -105
- data/skills/fp/references/cli.md +304 -3
- metadata +19 -2
data/lib/fp/commands/propose.rb
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require_relative '../requirement_lint'
|
|
5
|
+
|
|
3
6
|
module Fp
|
|
4
7
|
module Commands
|
|
5
8
|
# fp propose --project <slug> --slug X --name "..." --why "..." --required a,b,c --required-types unit,e2e --acceptance "..." --parent <slug> --category <cat>
|
|
6
|
-
#
|
|
9
|
+
# fp propose --file requirements.yml [--dry-run]
|
|
10
|
+
# Create a new requirement as draft — one from flags, or a batch from a file.
|
|
11
|
+
#
|
|
12
|
+
# --dry-run validates and lints without writing (Fizzy #1786): an agent can check its
|
|
13
|
+
# wording, and a human can review a whole batch, before anything lands in the catalog.
|
|
14
|
+
# --file validates every entry first — slugs, parents, categories, source types,
|
|
15
|
+
# collisions with the catalog — and writes nothing if any of them is wrong.
|
|
7
16
|
class Propose < Base
|
|
8
17
|
help_summary 'Propose a new requirement (created as a draft; a human activates it)'
|
|
9
|
-
help_usage 'fp propose --project <slug> --slug <key> --name "..." [flags]'
|
|
18
|
+
help_usage 'fp propose --project <slug> --slug <key> --name "..." [flags]',
|
|
19
|
+
'fp propose --file requirements.yml [--dry-run]'
|
|
10
20
|
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
11
21
|
help_flag :slug, 'Initial key for the requirement', required: true
|
|
12
22
|
help_flag :name, 'Human-readable title (one product promise, present tense)', required: true
|
|
@@ -17,11 +27,20 @@ module Fp
|
|
|
17
27
|
help_flag :parent, "Parent requirement's slug (nests one level; subset of parent's surfaces)"
|
|
18
28
|
help_flag :category, 'One of: functional, non-functional, ux, performance, security, compliance'
|
|
19
29
|
help_flag :tags, 'Comma-separated free-form labels'
|
|
20
|
-
help_flag :source_type, 'Where it came from: fizzy, discord, github_issue, jira, notion, manual, ai_chat'
|
|
30
|
+
help_flag :source_type, 'Where it came from: fizzy, discord, github_issue, jira, notion, manual, ai_chat, repo_scan (an agent read the code)'
|
|
21
31
|
help_flag :source_url, 'URL of the originating card/issue/thread'
|
|
22
32
|
help_flag :source_context, 'Free-form note about the origin'
|
|
33
|
+
help_flag :source_ref, 'A place in the repo it was read from, e.g. app/models/order.rb:42 (repeatable)'
|
|
34
|
+
help_flag :file, 'YAML or JSON file of requirements to propose in one batch (validated all-or-nothing)'
|
|
35
|
+
help_flag :dry_run, 'Validate and lint the wording; write nothing'
|
|
23
36
|
help_example 'fp propose --project stowzilla --slug print_qr --name "Print QR on container label" --why "Staff mis-type the ID without a scannable code" --required api,customer_ios --acceptance "QR scans to the container detail page"'
|
|
24
|
-
|
|
37
|
+
help_example 'fp propose --file requirements.yml --dry-run', 'Lint a batch and show what would be created'
|
|
38
|
+
help_example 'fp propose --file requirements.yml --source-type repo_scan', 'Create the batch; flags are defaults for every entry'
|
|
39
|
+
help_note "Agents can only create drafts. A human activates it in the web app.\n" \
|
|
40
|
+
"A --file holds `requirements:` (a list of entries with slug, name, why, acceptance,\n" \
|
|
41
|
+
"required, required_types, parent, category, tags, source_type, source_url,\n" \
|
|
42
|
+
"source_context, source_refs) and optional `defaults:` merged into every entry.\n" \
|
|
43
|
+
'A parent may be another entry in the same file; it is created first.'
|
|
25
44
|
|
|
26
45
|
KNOWN_FLAGS = {
|
|
27
46
|
project: :string,
|
|
@@ -36,70 +55,46 @@ module Fp
|
|
|
36
55
|
source_type: :string,
|
|
37
56
|
source_url: :string,
|
|
38
57
|
source_context: :string,
|
|
39
|
-
tags: :string
|
|
58
|
+
tags: :string,
|
|
59
|
+
file: :string,
|
|
60
|
+
dry_run: :boolean
|
|
40
61
|
}.freeze
|
|
41
62
|
|
|
42
63
|
VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
|
|
43
|
-
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
|
|
64
|
+
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat repo_scan].freeze
|
|
65
|
+
|
|
66
|
+
# Keys an entry in a --file may carry. Anything else is refused, so a typo like
|
|
67
|
+
# `acceptence` fails the batch instead of silently dropping the criteria.
|
|
68
|
+
FILE_KEYS = %w[slug name title why acceptance required required_surfaces required_types
|
|
69
|
+
required_test_types parent category tags source_type source_url
|
|
70
|
+
source_context source_refs].freeze
|
|
44
71
|
|
|
45
72
|
def run(args)
|
|
73
|
+
source_refs, args = extract_repeated(args, '--source-ref')
|
|
46
74
|
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
47
|
-
|
|
48
75
|
require_project!(opts)
|
|
76
|
+
|
|
77
|
+
return run_batch(opts, source_refs) if opts[:file]
|
|
78
|
+
|
|
49
79
|
require_flag(opts, :slug, '--slug is required (initial key for the requirement)')
|
|
50
80
|
require_flag(opts, :name, '--name is required (human-readable title)')
|
|
51
81
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
82
|
+
spec = spec_from_flags(opts, source_refs)
|
|
83
|
+
errors = validate_spec(spec)
|
|
84
|
+
unless errors.empty?
|
|
85
|
+
errors.each { |e| output.error(e) }
|
|
55
86
|
exit 1
|
|
56
87
|
end
|
|
57
88
|
|
|
58
|
-
if opts[:source_type] && !VALID_SOURCE_TYPES.include?(opts[:source_type])
|
|
59
|
-
output.error("Invalid source type '#{opts[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}")
|
|
60
|
-
exit 1
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
# Parse and validate required test types (comma-separated).
|
|
64
|
-
required_test_types = parse_test_types(opts[:required_types])
|
|
65
|
-
|
|
66
89
|
project_id = resolve_project_id(opts[:project])
|
|
90
|
+
return dry_run_single(spec, project_id) if opts[:dry_run]
|
|
67
91
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
opts[:required].split(',').map(&:strip)
|
|
71
|
-
else
|
|
72
|
-
[]
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
params = {
|
|
76
|
-
slug: opts[:slug],
|
|
77
|
-
title: opts[:name],
|
|
78
|
-
project_id: project_id,
|
|
79
|
-
required_surfaces: required_surfaces
|
|
80
|
-
}
|
|
81
|
-
params[:required_test_types] = required_test_types unless required_test_types.empty?
|
|
82
|
-
params[:why] = opts[:why] if opts[:why]
|
|
83
|
-
params[:acceptance] = opts[:acceptance] if opts[:acceptance]
|
|
84
|
-
params[:category] = opts[:category] if opts[:category]
|
|
85
|
-
params[:source_type] = opts[:source_type] if opts[:source_type]
|
|
86
|
-
params[:source_url] = opts[:source_url] if opts[:source_url]
|
|
87
|
-
params[:source_context] = opts[:source_context] if opts[:source_context]
|
|
88
|
-
|
|
89
|
-
# Tags are comma-separated. Send the raw list — the API normalizes
|
|
90
|
-
# (downcase/trim/dedupe) and validates.
|
|
91
|
-
if opts[:tags]
|
|
92
|
-
params[:tags] = opts[:tags].split(',').map(&:strip).reject(&:empty?)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Resolve parent slug to parent_id if provided
|
|
96
|
-
if opts[:parent]
|
|
97
|
-
parent_id = resolve_parent_id(project_id, opts[:parent])
|
|
98
|
-
params[:parent_id] = parent_id
|
|
99
|
-
end
|
|
92
|
+
params = params_for(spec, project_id)
|
|
93
|
+
params[:parent_id] = resolve_parent_id(project_id, spec[:parent]) if spec[:parent]
|
|
100
94
|
|
|
95
|
+
lint_warnings(spec)
|
|
101
96
|
# Note: status is NOT sent - the API enforces draft for agents
|
|
102
|
-
|
|
97
|
+
warn_if_not_production!
|
|
103
98
|
result = client.create_requirement(params)
|
|
104
99
|
|
|
105
100
|
unless result[:ok]
|
|
@@ -108,8 +103,10 @@ module Fp
|
|
|
108
103
|
end
|
|
109
104
|
|
|
110
105
|
requirement = result[:data]['requirement']
|
|
106
|
+
feedback = draft_feedback(requirement)
|
|
107
|
+
data = result[:data].merge('analysis' => feedback[:analysis], 'analysis_error' => feedback[:error]).compact
|
|
111
108
|
|
|
112
|
-
output.success(
|
|
109
|
+
output.success(data) do
|
|
113
110
|
puts "Created requirement '#{requirement['slug']}' as DRAFT."
|
|
114
111
|
puts
|
|
115
112
|
puts " Title: #{requirement['title']}"
|
|
@@ -120,16 +117,401 @@ module Fp
|
|
|
120
117
|
unless (requirement['required_test_types'] || []).empty?
|
|
121
118
|
puts " Test types: #{(requirement['required_test_types'] || []).join(', ')} (each must have evidence on every surface)"
|
|
122
119
|
end
|
|
123
|
-
puts " Parent: #{
|
|
120
|
+
puts " Parent: #{spec[:parent] || '(none)'}" if spec[:parent]
|
|
124
121
|
puts
|
|
125
122
|
puts '⚠️ This requirement is a DRAFT.'
|
|
126
123
|
puts ' A human must activate it in the web app before it appears in the parity matrix.'
|
|
127
124
|
puts ' Agents cannot activate requirements.'
|
|
125
|
+
print_feedback(feedback, requirement['slug'])
|
|
128
126
|
end
|
|
129
127
|
end
|
|
130
128
|
|
|
131
129
|
private
|
|
132
130
|
|
|
131
|
+
# --- Building and checking a spec ---------------------------------------------
|
|
132
|
+
|
|
133
|
+
# A requirement to propose, normalized from flags or a file entry.
|
|
134
|
+
def spec_from_flags(opts, source_refs)
|
|
135
|
+
{
|
|
136
|
+
slug: opts[:slug],
|
|
137
|
+
title: opts[:name],
|
|
138
|
+
why: opts[:why],
|
|
139
|
+
acceptance: opts[:acceptance],
|
|
140
|
+
required_surfaces: split_list(opts[:required]),
|
|
141
|
+
required_test_types: parse_test_types(opts[:required_types]),
|
|
142
|
+
parent: opts[:parent],
|
|
143
|
+
category: opts[:category],
|
|
144
|
+
tags: split_list(opts[:tags]),
|
|
145
|
+
source_type: opts[:source_type],
|
|
146
|
+
source_url: opts[:source_url],
|
|
147
|
+
source_context: opts[:source_context],
|
|
148
|
+
source_refs: source_refs.map(&:strip).reject(&:empty?)
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def validate_spec(spec)
|
|
153
|
+
errors = []
|
|
154
|
+
errors << '--slug is required (initial key for the requirement)' if blank?(spec[:slug])
|
|
155
|
+
errors << '--name is required (human-readable title)' if blank?(spec[:title])
|
|
156
|
+
# Validated client-side for better UX (fail fast with the valid list).
|
|
157
|
+
if spec[:category] && !VALID_CATEGORIES.include?(spec[:category])
|
|
158
|
+
errors << "Invalid category '#{spec[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}"
|
|
159
|
+
end
|
|
160
|
+
if spec[:source_type] && !VALID_SOURCE_TYPES.include?(spec[:source_type])
|
|
161
|
+
errors << "Invalid source type '#{spec[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}"
|
|
162
|
+
end
|
|
163
|
+
if spec[:source_refs].any? { |r| r.include?("\n") }
|
|
164
|
+
errors << 'Each --source-ref must be a single line, e.g. app/models/order.rb:42'
|
|
165
|
+
end
|
|
166
|
+
errors
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def params_for(spec, project_id)
|
|
170
|
+
params = {
|
|
171
|
+
slug: spec[:slug],
|
|
172
|
+
title: spec[:title],
|
|
173
|
+
project_id: project_id,
|
|
174
|
+
required_surfaces: spec[:required_surfaces]
|
|
175
|
+
}
|
|
176
|
+
params[:required_test_types] = spec[:required_test_types] unless spec[:required_test_types].empty?
|
|
177
|
+
%i[why acceptance category source_type source_url source_context].each do |key|
|
|
178
|
+
params[key] = spec[key] if spec[key]
|
|
179
|
+
end
|
|
180
|
+
params[:source_refs] = spec[:source_refs] unless spec[:source_refs].empty?
|
|
181
|
+
# Send the raw list — the API normalizes (downcase/trim/dedupe) and validates.
|
|
182
|
+
params[:tags] = spec[:tags] unless spec[:tags].empty?
|
|
183
|
+
params
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def lint_for(spec)
|
|
187
|
+
RequirementLint.check(spec).map { |f| { 'field' => f.field, 'message' => f.message } }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Wording notes on stderr, so the human output reads as before and --json stays clean.
|
|
191
|
+
def lint_warnings(spec)
|
|
192
|
+
notes = lint_for(spec)
|
|
193
|
+
return if notes.empty?
|
|
194
|
+
|
|
195
|
+
$stderr.puts "Wording notes for '#{spec[:slug]}' (run with --dry-run to check before writing):"
|
|
196
|
+
notes.each { |n| $stderr.puts " - #{n['field']} #{n['message']}" }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# --- Dry run -------------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def dry_run_single(spec, project_id)
|
|
202
|
+
existing = catalog(project_id)
|
|
203
|
+
problems = catalog_problems([spec], existing)
|
|
204
|
+
lint = lint_for(spec)
|
|
205
|
+
data = { dry_run: true, valid: problems.empty?, requirement: preview_params(spec), problems: problems, lint: lint }
|
|
206
|
+
|
|
207
|
+
output.success(data) do
|
|
208
|
+
puts "Dry run — nothing was written. fp propose would create '#{spec[:slug]}' as a DRAFT:"
|
|
209
|
+
print_preview(spec)
|
|
210
|
+
print_problems(problems)
|
|
211
|
+
print_lint(spec[:slug], lint)
|
|
212
|
+
end
|
|
213
|
+
exit 1 unless problems.empty?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def preview_params(spec)
|
|
217
|
+
params_for(spec, nil).reject { |k, _| k == :project_id }.merge(parent: spec[:parent]).compact
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def print_preview(spec)
|
|
221
|
+
puts " Title: #{spec[:title]}"
|
|
222
|
+
puts " Surfaces: #{spec[:required_surfaces].join(', ')}" unless spec[:required_surfaces].empty?
|
|
223
|
+
puts " Test types: #{spec[:required_test_types].join(', ')}" unless spec[:required_test_types].empty?
|
|
224
|
+
puts " Parent: #{spec[:parent]}" if spec[:parent]
|
|
225
|
+
puts " Category: #{spec[:category]}" if spec[:category]
|
|
226
|
+
puts " Why: #{spec[:why]}" if spec[:why]
|
|
227
|
+
puts " Acceptance: #{spec[:acceptance]}" if spec[:acceptance]
|
|
228
|
+
source = [spec[:source_type], spec[:source_url]].compact.join(' ')
|
|
229
|
+
puts " Source: #{source}" unless source.empty?
|
|
230
|
+
spec[:source_refs].each { |r| puts " #{r}" }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def print_problems(problems)
|
|
234
|
+
return if problems.empty?
|
|
235
|
+
|
|
236
|
+
puts
|
|
237
|
+
puts "✗ #{problems.length} problem(s) would stop this from being created:"
|
|
238
|
+
problems.each { |p| puts " - #{p}" }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def print_lint(slug, lint)
|
|
242
|
+
puts
|
|
243
|
+
if lint.empty?
|
|
244
|
+
puts "✓ No wording notes for '#{slug}'."
|
|
245
|
+
else
|
|
246
|
+
puts "Wording notes for '#{slug}' (advice, not errors — a human decides):"
|
|
247
|
+
lint.each { |n| puts " - #{n['field']} #{n['message']}" }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# --- Batch from a file ----------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
def run_batch(opts, flag_refs)
|
|
254
|
+
entries = load_entries(opts[:file])
|
|
255
|
+
defaults = spec_from_flags(opts.merge(slug: nil, name: nil), flag_refs)
|
|
256
|
+
|
|
257
|
+
specs = []
|
|
258
|
+
errors = []
|
|
259
|
+
entries[:list].each_with_index do |entry, i|
|
|
260
|
+
label = "entry #{i + 1}#{entry.is_a?(Hash) && entry['slug'] ? " (#{entry['slug']})" : ''}"
|
|
261
|
+
unless entry.is_a?(Hash)
|
|
262
|
+
errors << "#{label}: must be a mapping of fields, got #{entry.class}"
|
|
263
|
+
next
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
merged = entries[:defaults].merge(entry)
|
|
267
|
+
unknown = merged.keys - FILE_KEYS
|
|
268
|
+
errors << "#{label}: unknown field(s) #{unknown.join(', ')} (allowed: #{FILE_KEYS.join(', ')})" unless unknown.empty?
|
|
269
|
+
|
|
270
|
+
spec = spec_from_entry(merged, defaults)
|
|
271
|
+
validate_spec(spec).each { |e| errors << "#{label}: #{e.sub(/\A--(slug|name) is required.*/) { "#{Regexp.last_match(1)} is required" }}" }
|
|
272
|
+
specs << spec
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
if specs.empty? && errors.empty?
|
|
276
|
+
output.error("#{opts[:file]} has no requirements to propose")
|
|
277
|
+
exit 1
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
unless errors.empty?
|
|
281
|
+
summary = "#{opts[:file]}: #{errors.length} problem(s); nothing was created."
|
|
282
|
+
if output.json?
|
|
283
|
+
output.error("#{summary} #{errors.join('; ')}")
|
|
284
|
+
else
|
|
285
|
+
output.error(summary)
|
|
286
|
+
errors.each { |e| warn " #{e}" }
|
|
287
|
+
end
|
|
288
|
+
exit 1
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
project_id = resolve_project_id(opts[:project])
|
|
292
|
+
existing = catalog(project_id)
|
|
293
|
+
problems = catalog_problems(specs, existing)
|
|
294
|
+
ordered = parents_first(specs)
|
|
295
|
+
lint = ordered.to_h { |s| [s[:slug], lint_for(s)] }
|
|
296
|
+
|
|
297
|
+
if opts[:dry_run] || !problems.empty?
|
|
298
|
+
data = { dry_run: !!opts[:dry_run], valid: problems.empty?, problems: problems,
|
|
299
|
+
requirements: ordered.map { |s| preview_params(s).merge(lint: lint[s[:slug]]) } }
|
|
300
|
+
output.success(data) do
|
|
301
|
+
heading = opts[:dry_run] ? 'Dry run — nothing was written.' : 'Nothing was created.'
|
|
302
|
+
puts "#{heading} #{ordered.length} requirement(s) in #{opts[:file]}:"
|
|
303
|
+
print_batch_table(ordered, lint)
|
|
304
|
+
print_problems(problems)
|
|
305
|
+
print_batch_lint(ordered, lint)
|
|
306
|
+
end
|
|
307
|
+
exit 1 unless problems.empty?
|
|
308
|
+
return
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
warn_if_not_production!
|
|
312
|
+
create_batch(ordered, project_id, existing, lint, opts)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def load_entries(path)
|
|
316
|
+
unless File.file?(path)
|
|
317
|
+
output.error("No such file: #{path}")
|
|
318
|
+
exit 1
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
doc = begin
|
|
322
|
+
YAML.safe_load(File.read(path), aliases: false)
|
|
323
|
+
rescue Psych::Exception => e
|
|
324
|
+
output.error("Could not parse #{path}: #{e.message}")
|
|
325
|
+
exit 1
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
case doc
|
|
329
|
+
when Array
|
|
330
|
+
{ list: doc, defaults: {} }
|
|
331
|
+
when Hash
|
|
332
|
+
defaults = doc['defaults'] || {}
|
|
333
|
+
unless doc['requirements'].is_a?(Array) && defaults.is_a?(Hash)
|
|
334
|
+
output.error("#{path} must hold a `requirements:` list (and optionally a `defaults:` mapping), or be a list")
|
|
335
|
+
exit 1
|
|
336
|
+
end
|
|
337
|
+
{ list: doc['requirements'], defaults: defaults.transform_keys(&:to_s) }
|
|
338
|
+
else
|
|
339
|
+
output.error("#{path} must hold a `requirements:` list, or be a list")
|
|
340
|
+
exit 1
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# A file entry wins over its `defaults:`, which win over the command's flags.
|
|
345
|
+
def spec_from_entry(entry, flag_defaults)
|
|
346
|
+
list = ->(v) { v.is_a?(Array) ? v.map { |x| x.to_s.strip }.reject(&:empty?) : split_list(v&.to_s) }
|
|
347
|
+
fetch = ->(*keys) { keys.map { |k| entry[k] }.find { |v| !v.nil? } }
|
|
348
|
+
str = ->(*keys) { v = fetch.call(*keys); v.nil? ? nil : v.to_s }
|
|
349
|
+
|
|
350
|
+
refs = fetch.call('source_refs')
|
|
351
|
+
types = fetch.call('required_types', 'required_test_types')
|
|
352
|
+
surfaces = fetch.call('required', 'required_surfaces')
|
|
353
|
+
tags = fetch.call('tags')
|
|
354
|
+
{
|
|
355
|
+
slug: str.call('slug'),
|
|
356
|
+
title: str.call('name', 'title'),
|
|
357
|
+
why: str.call('why'),
|
|
358
|
+
acceptance: str.call('acceptance'),
|
|
359
|
+
required_surfaces: surfaces.nil? ? flag_defaults[:required_surfaces] : list.call(surfaces),
|
|
360
|
+
required_test_types: types.nil? ? flag_defaults[:required_test_types] : list.call(types).map(&:downcase).uniq,
|
|
361
|
+
parent: str.call('parent') || flag_defaults[:parent],
|
|
362
|
+
category: str.call('category') || flag_defaults[:category],
|
|
363
|
+
tags: tags.nil? ? flag_defaults[:tags] : list.call(tags),
|
|
364
|
+
source_type: str.call('source_type') || flag_defaults[:source_type],
|
|
365
|
+
source_url: str.call('source_url') || flag_defaults[:source_url],
|
|
366
|
+
source_context: str.call('source_context') || flag_defaults[:source_context],
|
|
367
|
+
source_refs: refs.nil? ? flag_defaults[:source_refs] : Array(refs).map { |r| r.to_s.strip }.reject(&:empty?)
|
|
368
|
+
}
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Problems only the catalog can reveal: a slug already taken, a parent that
|
|
372
|
+
# doesn't exist or is itself a child (nesting is one level), and duplicates
|
|
373
|
+
# within the batch.
|
|
374
|
+
def catalog_problems(specs, existing)
|
|
375
|
+
by_slug = existing.to_h { |r| [r['slug'], r] }
|
|
376
|
+
in_batch = specs.to_h { |s| [s[:slug], s] }
|
|
377
|
+
problems = []
|
|
378
|
+
|
|
379
|
+
specs.group_by { |s| s[:slug] }.each do |slug, group|
|
|
380
|
+
problems << "'#{slug}' appears #{group.length} times in the batch" if group.length > 1
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
specs.each do |spec|
|
|
384
|
+
problems << "'#{spec[:slug]}' already exists in this project (#{by_slug[spec[:slug]]['status']})" if by_slug.key?(spec[:slug])
|
|
385
|
+
next unless spec[:parent]
|
|
386
|
+
|
|
387
|
+
if (parent = in_batch[spec[:parent]])
|
|
388
|
+
if parent[:parent]
|
|
389
|
+
problems << "'#{spec[:slug]}' names parent '#{spec[:parent]}', which is itself a child — requirements nest one level"
|
|
390
|
+
end
|
|
391
|
+
elsif (parent = by_slug[spec[:parent]])
|
|
392
|
+
unless blank?(parent['parent_id'])
|
|
393
|
+
problems << "'#{spec[:slug]}' names parent '#{spec[:parent]}', which is itself a child — requirements nest one level"
|
|
394
|
+
end
|
|
395
|
+
else
|
|
396
|
+
problems << "'#{spec[:slug]}' names parent '#{spec[:parent]}', which is neither in this project nor in the batch"
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
problems
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Entries whose parent is another entry go after it.
|
|
403
|
+
def parents_first(specs)
|
|
404
|
+
batch_slugs = specs.map { |s| s[:slug] }
|
|
405
|
+
top, children = specs.partition { |s| s[:parent].nil? || !batch_slugs.include?(s[:parent]) }
|
|
406
|
+
top + children
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def create_batch(ordered, project_id, existing, lint, opts)
|
|
410
|
+
ids = existing.to_h { |r| [r['slug'], r['id']] }
|
|
411
|
+
created = []
|
|
412
|
+
|
|
413
|
+
ordered.each do |spec|
|
|
414
|
+
params = params_for(spec, project_id)
|
|
415
|
+
params[:parent_id] = ids.fetch(spec[:parent]) if spec[:parent]
|
|
416
|
+
result = client.create_requirement(params)
|
|
417
|
+
|
|
418
|
+
unless result[:ok]
|
|
419
|
+
remaining = ordered.drop(created.length).map { |s| s[:slug] }
|
|
420
|
+
output.error("Creating '#{spec[:slug]}' failed: #{result[:error]}", status: result[:status])
|
|
421
|
+
unless output.json?
|
|
422
|
+
warn "Created #{created.length} of #{ordered.length} before that: #{created.map { |c| c['slug'] }.join(', ')}" unless created.empty?
|
|
423
|
+
warn "Not created: #{remaining.join(', ')}. Fix the entry and run again with only those."
|
|
424
|
+
end
|
|
425
|
+
exit 1
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
requirement = result[:data]['requirement'] || {}
|
|
429
|
+
ids[spec[:slug]] = requirement['id']
|
|
430
|
+
created << requirement.merge('parent' => spec[:parent])
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
output.success({ created: created, lint: lint }) do
|
|
434
|
+
puts "Created #{created.length} requirement(s) from #{opts[:file]} as DRAFTS:"
|
|
435
|
+
print_batch_table(ordered, lint, created)
|
|
436
|
+
print_batch_lint(ordered, lint)
|
|
437
|
+
puts
|
|
438
|
+
puts '⚠️ These are DRAFTS. A human must activate them in the web app; agents cannot.'
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def print_batch_table(ordered, lint, created = nil)
|
|
443
|
+
status = created&.to_h { |c| [c['slug'], c['status']] }
|
|
444
|
+
rows = ordered.map do |s|
|
|
445
|
+
[s[:slug], truncate(s[:title].to_s, 50), s[:parent] || '', s[:required_surfaces].join(','),
|
|
446
|
+
status ? status[s[:slug]].to_s : 'would create', lint[s[:slug]].length.to_s]
|
|
447
|
+
end
|
|
448
|
+
puts
|
|
449
|
+
output.table(%w[SLUG TITLE PARENT SURFACES STATUS NOTES], rows)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
def print_batch_lint(ordered, lint)
|
|
453
|
+
noted = ordered.reject { |s| lint[s[:slug]].empty? }
|
|
454
|
+
return if noted.empty?
|
|
455
|
+
|
|
456
|
+
puts
|
|
457
|
+
puts 'Wording notes (advice, not errors — a human decides):'
|
|
458
|
+
noted.each do |s|
|
|
459
|
+
puts " #{s[:slug]}:"
|
|
460
|
+
lint[s[:slug]].each { |n| puts " - #{n['field']} #{n['message']}" }
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# --- Helpers -------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def catalog(project_id)
|
|
467
|
+
result = client.list_requirements(project_id: project_id)
|
|
468
|
+
unless result[:ok]
|
|
469
|
+
output.error("Failed to read the project's requirements: #{result[:error]}", status: result[:status])
|
|
470
|
+
exit 1
|
|
471
|
+
end
|
|
472
|
+
result[:data]['requirements'] || []
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def split_list(raw)
|
|
476
|
+
return [] if raw.nil?
|
|
477
|
+
|
|
478
|
+
raw.split(',').map(&:strip).reject(&:empty?)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def blank?(value)
|
|
482
|
+
value.nil? || value.to_s.strip.empty?
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Quality feedback on the draft just created, as part of the same command, so an
|
|
486
|
+
# agent learns at once that its draft is weak or shouldn't exist and can fix or
|
|
487
|
+
# withdraw it before a human looks (requirement_analysis_runs_on_draft). It never
|
|
488
|
+
# undoes the draft: when analysis is unavailable the draft stands and we say so.
|
|
489
|
+
def draft_feedback(requirement)
|
|
490
|
+
result = client.analyze_requirement(requirement['id'])
|
|
491
|
+
return { analysis: result[:data]['analysis'] } if result[:ok] && result[:data].is_a?(Hash) && result[:data]['analysis']
|
|
492
|
+
|
|
493
|
+
{ error: result[:error] || 'no analysis came back' }
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def print_feedback(feedback, slug)
|
|
497
|
+
puts
|
|
498
|
+
if feedback[:error]
|
|
499
|
+
puts "Quality feedback could not be produced: #{feedback[:error]}"
|
|
500
|
+
puts "The draft was still created. Try again later with `fp analyze #{slug}`."
|
|
501
|
+
return
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
analysis = feedback[:analysis]
|
|
505
|
+
puts "Quality feedback: #{analysis['verdict'] || 'no verdict'}"
|
|
506
|
+
Array(analysis['issues']).each do |issue|
|
|
507
|
+
puts " - #{issue['type'].to_s.tr('_', ' ')}"
|
|
508
|
+
end
|
|
509
|
+
Array(analysis['suggestions']).first(3).each { |suggestion| puts " → #{suggestion}" }
|
|
510
|
+
return unless %w[questionable needs_work].include?(analysis['verdict'])
|
|
511
|
+
|
|
512
|
+
puts "Revise it with `fp propose-change #{slug} ...`, or withdraw it if it shouldn't be a requirement."
|
|
513
|
+
end
|
|
514
|
+
|
|
133
515
|
# Parse a comma-separated --required-types list into a normalized array. Returns []
|
|
134
516
|
# when the flag is absent (meaning "any test counts / inherit project default").
|
|
135
517
|
#
|
|
@@ -23,16 +23,17 @@ 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
|
+
help_flag :source_ref, 'A place in the repo that prompted it, e.g. app/models/order.rb:42 (repeatable)'
|
|
32
33
|
help_example 'fp propose-change print_qr --project stowzilla --acceptance "QR scans after 24h at -20C" --reason "Cold-chain rollout"'
|
|
33
34
|
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
|
-
"
|
|
35
|
+
help_note "Applies the change and drops the requirement to draft for a human to review. --reason and\n" \
|
|
36
|
+
"--source-* record what prompted the proposal in its history; the requirement's own origin is unchanged."
|
|
36
37
|
|
|
37
38
|
KNOWN_FLAGS = {
|
|
38
39
|
project: :string, name: :string, why: :string, acceptance: :string,
|
|
@@ -43,7 +44,7 @@ module Fp
|
|
|
43
44
|
}.freeze
|
|
44
45
|
|
|
45
46
|
VALID_CATEGORIES = %w[functional non-functional ux performance security compliance].freeze
|
|
46
|
-
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
|
|
47
|
+
VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat repo_scan].freeze
|
|
47
48
|
|
|
48
49
|
# Flags that describe a proposed new value, mapped to their API field.
|
|
49
50
|
FIELD_FLAGS = {
|
|
@@ -55,7 +56,9 @@ module Fp
|
|
|
55
56
|
}.freeze
|
|
56
57
|
|
|
57
58
|
def run(args)
|
|
59
|
+
source_refs, args = extract_repeated(args, '--source-ref')
|
|
58
60
|
opts, positional = parse_flags(args, KNOWN_FLAGS)
|
|
61
|
+
opts[:source_refs] = source_refs.map(&:strip).reject(&:empty?)
|
|
59
62
|
require_project!(opts)
|
|
60
63
|
|
|
61
64
|
slug = positional.first
|
|
@@ -82,6 +85,7 @@ module Fp
|
|
|
82
85
|
exit 1
|
|
83
86
|
end
|
|
84
87
|
|
|
88
|
+
warn_if_not_production!
|
|
85
89
|
result = client.propose_requirement_change(requirement['id'], params)
|
|
86
90
|
unless result[:ok]
|
|
87
91
|
if result[:status] == 409 && result[:details].is_a?(Hash)
|
|
@@ -220,6 +224,7 @@ module Fp
|
|
|
220
224
|
params[:source_type] = opts[:source_type] if opts[:source_type]
|
|
221
225
|
params[:source_url] = opts[:source_url] if opts[:source_url]
|
|
222
226
|
params[:source_context] = opts[:source_context] if opts[:source_context]
|
|
227
|
+
params[:source_refs] = opts[:source_refs] unless opts[:source_refs].nil? || opts[:source_refs].empty?
|
|
223
228
|
|
|
224
229
|
params
|
|
225
230
|
end
|
|
@@ -290,6 +295,7 @@ module Fp
|
|
|
290
295
|
puts " Source: #{source['type']}" if source['type']
|
|
291
296
|
puts " URL: #{source['url']}" if source['url']
|
|
292
297
|
puts " Context: #{source['context']}" if source['context']
|
|
298
|
+
Array(source['refs']).each { |ref| puts " Ref: #{ref}" }
|
|
293
299
|
puts
|
|
294
300
|
end
|
|
295
301
|
|