featureparity 0.0.28 → 0.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- # Create a new requirement as draft
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
- help_note 'Agents can only create drafts. A human activates it in the web app.'
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
- # Validate category client-side for better UX (fail fast with helpful message)
53
- if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
54
- output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")
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
- # Parse required surfaces (comma-separated)
69
- required_surfaces = if opts[:required]
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]
@@ -122,7 +117,7 @@ module Fp
122
117
  unless (requirement['required_test_types'] || []).empty?
123
118
  puts " Test types: #{(requirement['required_test_types'] || []).join(', ')} (each must have evidence on every surface)"
124
119
  end
125
- puts " Parent: #{opts[:parent] || '(none)'}" if opts[:parent]
120
+ puts " Parent: #{spec[:parent] || '(none)'}" if spec[:parent]
126
121
  puts
127
122
  puts '⚠️ This requirement is a DRAFT.'
128
123
  puts ' A human must activate it in the web app before it appears in the parity matrix.'
@@ -133,6 +128,360 @@ module Fp
133
128
 
134
129
  private
135
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
+
136
485
  # Quality feedback on the draft just created, as part of the same command, so an
137
486
  # agent learns at once that its draft is weak or shouldn't exist and can fix or
138
487
  # withdraw it before a human looks (requirement_analysis_runs_on_draft). It never
@@ -29,6 +29,7 @@ module Fp
29
29
  help_flag :source_type, "Where this proposal came from (recorded on the change; the requirement's origin is kept)"
30
30
  help_flag :source_url, 'URL of the card/issue/thread that prompted this proposal'
31
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
35
  help_note "Applies the change and drops the requirement to draft for a human to review. --reason and\n" \
@@ -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