featureparity 0.0.6 → 0.0.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59cbbdb1a7c4c7af5ab1d7016d22c7fdf670855c9e75a5c9d7350752c885b625
4
- data.tar.gz: 25d42454129c6d27c222e5d512578991b13a2abda65af67f8f39139550d24e96
3
+ metadata.gz: c622199401f105760e517583ace2bb9ec9da7f6f125d960144783ec82e2922b4
4
+ data.tar.gz: ab0b372febeff5b863f46d80d093ff8ac789bb4fdd2ae9ceab443f81a8b9a2f5
5
5
  SHA512:
6
- metadata.gz: 639bc26fbac4a013d0cef2051c438f1019ce915b5ce90537fe6cea0339f443f27234ae85062ba22f93cd989e7c6982390fb3be3068cd63bf658e081795243fca
7
- data.tar.gz: 0db47c4854e39bd6410c44839c26338d79833d129e6f68d3de26e83afff12a45b7199348fee0f1528a35697caace3e8d8084ee31438ec58474d175dcaf9a040c
6
+ metadata.gz: ed416b54e77e1aa6e9ce490283811ac5b81443f34aef97cf9b47987719bc5b5497456c4fd3bb74b1f308b735ab07bc5682a06a4d13548cd48272135160692cd2
7
+ data.tar.gz: 6316ceda063c279ad46dd763c5ab9b1366a27cd00c1eb8d6f52b7ce21995c203040f86667e76a4a921e77d7a14c380ec85aaedb1ad0c4468e8284b185cf69a5c
data/lib/fp/cli.rb CHANGED
@@ -4,14 +4,19 @@ module Fp
4
4
  # Main CLI entry point - parses global flags and dispatches to commands
5
5
  class CLI
6
6
  COMMANDS = {
7
+ 'init' => Commands::Init,
7
8
  'projects' => Commands::Projects,
8
9
  'surfaces' => Commands::Surfaces,
9
10
  'list' => Commands::List,
10
11
  'show' => Commands::Show,
11
12
  'propose' => Commands::Propose,
13
+ 'propose-change' => Commands::ProposeChange,
14
+ 'history' => Commands::History,
12
15
  'report' => Commands::Report,
13
16
  'ci-report' => Commands::CiReport,
17
+ 'check' => Commands::Check,
14
18
  'matrix' => Commands::Matrix,
19
+ 'export' => Commands::Export,
15
20
  'profile' => Commands::Profile,
16
21
  'config' => Commands::ConfigCmd,
17
22
  'repos' => Commands::Repos,
@@ -36,8 +41,9 @@ module Fp
36
41
  exit 1
37
42
  end
38
43
 
39
- # Profile/config commands don't need auth
40
- needs_auth = !%w[profile config repos setup whoami version help].include?(command_name)
44
+ # Profile/config commands don't need auth. `init` is here too: it only writes
45
+ # files into the repo, and onboarding a repo must work before anyone has a key.
46
+ needs_auth = !%w[init profile config repos setup whoami version help].include?(command_name)
41
47
 
42
48
  if needs_auth
43
49
  config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
data/lib/fp/client.rb CHANGED
@@ -33,11 +33,12 @@ module Fp
33
33
  end
34
34
 
35
35
  # GET /requirements
36
- def list_requirements(project_id: nil, status: nil, category: nil)
36
+ def list_requirements(project_id: nil, status: nil, category: nil, tag: nil)
37
37
  params = {}
38
38
  params[:project_id] = project_id if project_id
39
39
  params[:status] = status if status
40
40
  params[:category] = category if category
41
+ params[:tag] = tag if tag
41
42
  get('/api/requirements', params)
42
43
  end
43
44
 
@@ -72,6 +73,30 @@ module Fp
72
73
  put("/api/requirements/#{id}", params)
73
74
  end
74
75
 
76
+ # GET /requirements/:id/changes
77
+ #
78
+ # A requirement's change history: what it was, what changed, who changed it, and
79
+ # what prompted the change. Includes the open proposal (if any) broken out.
80
+ def list_requirement_changes(requirement_id)
81
+ get("/api/requirements/#{requirement_id}/changes")
82
+ end
83
+
84
+ # POST /requirements/:id/changes
85
+ #
86
+ # Propose a modification, or a deletion when params[:removal] is true. The
87
+ # requirement drops back to draft until a human reviews it.
88
+ def propose_requirement_change(requirement_id, params)
89
+ post("/api/requirements/#{requirement_id}/changes", params)
90
+ end
91
+
92
+ # PUT /requirements/:id/changes/:change_id
93
+ #
94
+ # Approve or reject an open proposal. Humans only — an agent key gets a 403, which
95
+ # is deliberate and printed verbatim.
96
+ def review_requirement_change(requirement_id, change_id, params)
97
+ put("/api/requirements/#{requirement_id}/changes/#{change_id}", params)
98
+ end
99
+
75
100
  # GET /projects/:project_id/evidence — the receipts already filed for a project,
76
101
  # optionally narrowed to one requirement slug and/or surface.
77
102
  def list_evidence(project_id, slug: nil, surface: nil)
@@ -94,25 +119,46 @@ module Fp
94
119
  # writes passing/failing evidence per surface. It also marks previously
95
120
  # passing slugs that have vanished from the results as failing. This is the
96
121
  # half of the loop only CI is allowed to close (#1222).
97
- def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:)
98
- post("/api/projects/#{project_id}/ci_results", {
99
- ci_url: ci_url,
100
- surface: surface,
101
- junit_xml: junit_xml
102
- })
103
- end
122
+ def ingest_ci_results(project_id, ci_url:, surface:, junit_xml:, pr_url: nil)
123
+ body = {
124
+ ci_url: ci_url,
125
+ surface: surface,
126
+ junit_xml: junit_xml
127
+ }
128
+ # Only send pr_url when set, so a merged-branch run stays a plain ingest.
129
+ body[:pr_url] = pr_url if pr_url && !pr_url.empty?
104
130
 
105
- # GET /matrix (when API is ready)
106
- def get_matrix(project_id, format: :json)
107
- path = format == :csv ? '/api/matrix.csv' : '/api/matrix'
108
- get(path, { project_id: project_id })
131
+ post("/api/projects/#{project_id}/ci_results", body)
109
132
  end
110
133
 
111
- # GET /gaps (when API is ready)
112
- def get_gaps(project_id, surface: nil)
113
- params = { project_id: project_id }
134
+ # GET /projects/:project_id/parity the computed parity grid and gate verdict.
135
+ #
136
+ # This replaces the old get_matrix/get_gaps stubs, which pointed at /api/matrix
137
+ # and /api/gaps — routes that were never built. `fp matrix` and `fp list --gaps`
138
+ # never called them, and instead each guessed at coverage client-side from
139
+ # required_surfaces without ever reading Evidence. The server now does the
140
+ # cross-referencing so the matrix, the gaps list, and the CI gate agree.
141
+ #
142
+ # @param level [String, nil] enforcement level override; nil uses the project's own
143
+ # @param surface [String, nil] restrict the report to a single surface key
144
+ def get_parity(project_id, level: nil, surface: nil)
145
+ params = {}
146
+ params[:level] = level if level
114
147
  params[:surface] = surface if surface
115
- get('/api/gaps', params)
148
+ get("/api/projects/#{project_id}/parity", params)
149
+ end
150
+
151
+ # POST /projects/:project_id/parity/preview — the pull-request gate.
152
+ #
153
+ # Evaluates JUnit results as an overlay on the stored grid *without persisting them*,
154
+ # answering "would parity still hold if this merged?". GET parity can only describe
155
+ # what has already been recorded, which on a PR is the default branch's state; this
156
+ # is how a branch gets gated on its own results without writing evidence that would
157
+ # churn the live matrix.
158
+ def preview_parity(project_id, surface:, junit_xmls:, level: nil)
159
+ body = { surface: surface, junit_xmls: Array(junit_xmls) }
160
+ body[:level] = level if level
161
+ post("/api/projects/#{project_id}/parity/preview", body)
116
162
  end
117
163
 
118
164
  private
@@ -80,11 +80,60 @@ module Fp
80
80
  end
81
81
  end
82
82
 
83
+ # Resolve which project a command should act on, filling in opts[:project] from the
84
+ # repo-local .featureparity.yml when --project wasn't given.
85
+ #
86
+ # That fallback is what makes enforcement workable in CI and for agents: a
87
+ # generated workflow, and an agent in a fresh clone, shouldn't have to know (or
88
+ # duplicate) the project slug when the repo already declares it.
89
+ #
90
+ # Mutates opts rather than returning a separate value so the existing
91
+ # resolve_project_id / display code in each command needs no changes.
92
+ # Returns the slug; exits 1 with guidance when neither source has one.
93
+ def require_project!(opts, project_config = nil)
94
+ opts[:project] ||= (project_config || ProjectConfig.discover).project
95
+
96
+ unless opts[:project]
97
+ output.error(
98
+ '--project is required (or add `project: <slug>` to .featureparity.yml — see `fp init`)'
99
+ )
100
+ exit 1
101
+ end
102
+
103
+ opts[:project]
104
+ end
105
+
83
106
  def truncate(str, max)
84
107
  return str if str.nil? || str.length <= max
85
108
 
86
109
  str[0...max - 3] + '...'
87
110
  end
111
+
112
+ # Pull every occurrence of a repeatable flag out of args, returning
113
+ # [values, remaining_args].
114
+ #
115
+ # parse_flags keeps only the last occurrence of a repeated flag, so a flag that may
116
+ # legitimately appear more than once (--junit, for a suite split across shards) has
117
+ # to be extracted before the normal parse. Shared by `fp ci-report` and `fp check`.
118
+ def extract_repeated(args, flag)
119
+ values = []
120
+ rest = []
121
+ i = 0
122
+ while i < args.length
123
+ arg = args[i]
124
+ if arg == flag && i + 1 < args.length
125
+ values << args[i + 1]
126
+ i += 2
127
+ elsif arg.start_with?("#{flag}=")
128
+ values << arg.split('=', 2)[1]
129
+ i += 1
130
+ else
131
+ rest << arg
132
+ i += 1
133
+ end
134
+ end
135
+ [values, rest]
136
+ end
88
137
  end
89
138
  end
90
139
  end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # fp check [--project X] [--surface api] [--level covered] [--junit FILE] [--json]
6
+ #
7
+ # The enforcement gate. Fetches the parity report and exits non-zero when the
8
+ # project violates its enforcement level, which is what makes it usable as a
9
+ # required status check on a pull request.
10
+ #
11
+ # Two modes:
12
+ #
13
+ # fp check Gate on what has been recorded. Answers "is this
14
+ # project currently in violation?"
15
+ # fp check --junit r.xml Gate on *this run's* results, applied on top of the
16
+ # --surface api recorded grid but never saved. Answers "would parity
17
+ # still hold if this branch merged?"
18
+ #
19
+ # The second is the pull-request gate. Without it a PR run can only see the default
20
+ # branch's state, so it can't tell you that the branch in front of it deleted the only
21
+ # test covering a requirement. Reporting the branch's evidence for real would answer
22
+ # that too, but would let every in-progress branch churn the live matrix — so the
23
+ # results are evaluated and thrown away.
24
+ #
25
+ # Everything about *what counts as a violation* lives server-side in ParityReport —
26
+ # this command renders the verdict and turns it into an exit code. That split is
27
+ # deliberate: if the policy lived in the CLI, every repo would enforce whichever
28
+ # version of the gem its CI happened to install.
29
+ #
30
+ # Exit codes are the contract CI reads:
31
+ # 0 gate passed
32
+ # 1 gate failed (violations), or the request failed
33
+ #
34
+ # `--warn-only` reports violations and still exits 0, for landing the gate on a repo
35
+ # that isn't clean yet — you get the annotations without blocking every merge on day
36
+ # one. Prefer setting the project's level to `regression` over living on --warn-only
37
+ # forever, since a warn-only gate is one nobody reads.
38
+ class Check < Base
39
+ KNOWN_FLAGS = {
40
+ project: :string,
41
+ surface: :string,
42
+ level: :string,
43
+ junit: :string,
44
+ warn_only: :boolean
45
+ }.freeze
46
+
47
+ def run(args)
48
+ # Collect every --junit occurrence (parse_flags keeps only the last of a repeated
49
+ # flag), matching `fp ci-report`.
50
+ junit_paths, rest = extract_repeated(args, '--junit')
51
+ opts, = parse_flags(rest, KNOWN_FLAGS)
52
+
53
+ project_config = ProjectConfig.discover
54
+ project_slug = require_project!(opts, project_config)
55
+
56
+ surface = opts[:surface] || project_config.surface
57
+ level = opts[:level] || project_config.enforcement
58
+
59
+ project_id = resolve_project_id(project_slug)
60
+
61
+ result =
62
+ if junit_paths.empty?
63
+ client.get_parity(project_id, level: level, surface: surface)
64
+ else
65
+ preview(project_id, junit_paths, surface, level)
66
+ end
67
+
68
+ unless result[:ok]
69
+ output.error(result[:error], status: result[:status])
70
+ exit 1
71
+ end
72
+
73
+ report = result[:data]['parity'] || {}
74
+ violations = report['violations'] || []
75
+ passed = report['passing']
76
+
77
+ render(report, violations, passed, project_slug, opts)
78
+
79
+ # A failing gate is the whole point of the command, so it must be the exit code
80
+ # and not just something printed. --warn-only downgrades it to advisory.
81
+ exit 1 if !passed && !opts[:warn_only]
82
+ end
83
+
84
+ private
85
+
86
+ # Preview mode needs to know which surface the results belong to, because the
87
+ # server uses it to decide which cells the run had a chance to cover — and
88
+ # therefore which vanished tests it may legitimately flag.
89
+ def preview(project_id, junit_paths, surface, level)
90
+ unless surface
91
+ output.error(
92
+ '--surface is required with --junit (the surface these results are for). ' \
93
+ 'Set it via --surface or `surface:` in .featureparity.yml.'
94
+ )
95
+ exit 1
96
+ end
97
+
98
+ missing = junit_paths.reject { |p| File.file?(p) }
99
+ unless missing.empty?
100
+ output.error("JUnit file(s) not found: #{missing.join(', ')}")
101
+ exit 1
102
+ end
103
+
104
+ client.preview_parity(
105
+ project_id,
106
+ surface: surface,
107
+ junit_xmls: junit_paths.map { |p| File.read(p) },
108
+ level: level
109
+ )
110
+ end
111
+
112
+ def render(report, violations, passed, project_slug, opts)
113
+ payload = report.merge('project' => project_slug, 'warn_only' => !!opts[:warn_only])
114
+
115
+ output.success(payload) do
116
+ summary = report['summary'] || {}
117
+ scope = report['surface'] ? " (surface: #{report['surface']})" : ''
118
+ puts "FeatureParity gate: #{project_slug}#{scope}"
119
+ puts "Enforcement level: #{report['level']}"
120
+ if report['preview']
121
+ reported = Array(report['preview_surfaces']).join(', ')
122
+ puts "Mode: preview — evaluating this run's results#{reported.empty? ? '' : " for #{reported}"} (nothing saved)"
123
+ end
124
+ puts
125
+
126
+ if (report['surfaces'] || []).empty?
127
+ puts 'This project has no surfaces configured, so there is nothing to enforce.'
128
+ puts "Add one with: fp surfaces add <key> --project #{project_slug}"
129
+ next
130
+ end
131
+
132
+ if summary['requirements'].to_i.zero?
133
+ puts 'No requirements to measure. Nothing to enforce yet.'
134
+ next
135
+ end
136
+
137
+ print_state_counts(summary)
138
+ print_violations(violations)
139
+ print_pending_reviews(report, project_slug)
140
+ print_unscoped_warning(summary, project_slug)
141
+ print_verdict(passed, violations, opts)
142
+ end
143
+ end
144
+
145
+ def print_state_counts(summary)
146
+ counts = Parity.count_labels(summary)
147
+
148
+ puts "#{summary['requirements']} requirement(s) measured, #{summary['cells']} required surface(s)"
149
+ puts " #{counts.join(' ')}" unless counts.empty?
150
+ puts
151
+ end
152
+
153
+ # Requirements a proposed change has knocked back to draft. Informational, never a
154
+ # violation — a human's review latency is not something to fail an agent's build
155
+ # over. But they stay in the grid (proposing a change must not be a way out of the
156
+ # gate), and proposing marks their evidence `suspect`, so saying nothing here would
157
+ # leave a reader guessing why a row that was green went ⚠.
158
+ def print_pending_reviews(report, project_slug)
159
+ pending = report['pending_reviews'] || []
160
+ return if pending.empty?
161
+
162
+ puts "#{pending.size} requirement(s) awaiting review of a proposed change " \
163
+ "(#{Parity.pending_summary(pending)}):"
164
+ output.table(
165
+ %w[SLUG PROPOSED TITLE],
166
+ pending.map { |p| [p['slug'], p['kind'], truncate(p['title'].to_s, 48)] }
167
+ )
168
+ puts 'These still count toward the gate — proposing a change is not a way out of it.'
169
+ puts "Their evidence is suspect until the change is reviewed: fp history <slug>#{project_scope(project_slug)}"
170
+ puts
171
+ end
172
+
173
+ def print_violations(violations)
174
+ return if violations.empty?
175
+
176
+ puts "#{violations.size} violation(s):"
177
+ output.table(
178
+ %w[SLUG SURFACE STATE WHY],
179
+ violations.map do |v|
180
+ [v['slug'], v['surface'], "#{Parity.glyph(v['state'])} #{v['state']}", v['reason']]
181
+ end
182
+ )
183
+ puts
184
+ end
185
+
186
+ # Informational, never a violation: an active requirement naming no surface this
187
+ # project has can't be satisfied, so it would pass any gate silently. Worth
188
+ # saying out loud without blocking a merge on someone else's data-entry gap.
189
+ # `--project` is only worth echoing in a suggested command when the slug didn't
190
+ # come from .featureparity.yml — otherwise it's noise the agent would copy.
191
+ def project_scope(project_slug)
192
+ ProjectConfig.discover.project ? '' : " --project #{project_slug}"
193
+ end
194
+
195
+ def print_unscoped_warning(summary, project_slug)
196
+ unscoped = summary['unscoped'].to_i
197
+ return unless unscoped.positive?
198
+
199
+ puts "Note: #{unscoped} requirement(s) name no surface this project has, so " \
200
+ 'nothing can satisfy them.'
201
+ puts " Review with: fp list --project #{project_slug}"
202
+ puts
203
+ end
204
+
205
+ def print_verdict(passed, violations, opts)
206
+ if passed
207
+ puts 'Gate passed.'
208
+ elsif opts[:warn_only]
209
+ puts "Gate FAILED with #{violations.size} violation(s) — exiting 0 because --warn-only was given."
210
+ else
211
+ puts "Gate FAILED with #{violations.size} violation(s)."
212
+ end
213
+ end
214
+ end
215
+ end
216
+ end
@@ -25,11 +25,18 @@ module Fp
25
25
  # Multiple --junit flags may be passed to ingest several reports for the same
26
26
  # surface in one invocation (e.g. a suite split across shards). Each is
27
27
  # uploaded in turn.
28
+ #
29
+ # --pr <url> marks the run as a pull request's CI rather than the merged branch's.
30
+ # Passing tests are then recorded as `pr_passing` ("would pass once merged") instead
31
+ # of `passing`, and no merged evidence is demoted. Run it without --pr on pushes to
32
+ # the default branch to record the real `passing`, superseding any pr_passing a prior
33
+ # PR run left behind.
28
34
  class CiReport < Base
29
35
  KNOWN_FLAGS = {
30
36
  project: :string,
31
37
  surface: :string,
32
38
  ci_url: :string,
39
+ pr: :string,
33
40
  junit: :string
34
41
  }.freeze
35
42
 
@@ -39,7 +46,7 @@ module Fp
39
46
  junit_paths, rest = extract_repeated(args, '--junit')
40
47
  opts, _positional = parse_flags(rest, KNOWN_FLAGS)
41
48
 
42
- require_flag(opts, :project)
49
+ require_project!(opts)
43
50
  require_flag(opts, :surface)
44
51
  require_flag(opts, :ci_url)
45
52
 
@@ -64,7 +71,8 @@ module Fp
64
71
  project_id,
65
72
  ci_url: opts[:ci_url],
66
73
  surface: opts[:surface],
67
- junit_xml: xml
74
+ junit_xml: xml,
75
+ pr_url: opts[:pr]
68
76
  )
69
77
 
70
78
  unless result[:ok]
@@ -87,31 +95,12 @@ module Fp
87
95
  # Pull every occurrence of a repeatable flag (and its value) out of args,
88
96
  # returning [collected_values, remaining_args]. Leaves all other flags for
89
97
  # the normal parser.
90
- def extract_repeated(args, flag)
91
- values = []
92
- rest = []
93
- i = 0
94
- while i < args.length
95
- arg = args[i]
96
- if arg == flag && i + 1 < args.length
97
- values << args[i + 1]
98
- i += 2
99
- elsif arg.start_with?("#{flag}=")
100
- values << arg.split('=', 2)[1]
101
- i += 1
102
- else
103
- rest << arg
104
- i += 1
105
- end
106
- end
107
- [values, rest]
108
- end
109
-
110
98
  def emit_summary(opts, totals)
111
99
  data = {
112
100
  project: opts[:project],
113
101
  surface: opts[:surface],
114
102
  ci_url: opts[:ci_url],
103
+ pr_url: opts[:pr],
115
104
  evidence_created: totals[:created],
116
105
  evidence_updated: totals[:updated],
117
106
  missing_marked_failing: totals[:missing_failing],
@@ -119,11 +108,11 @@ module Fp
119
108
  }
120
109
 
121
110
  output.success(data) do
122
- puts "Ingested CI results for surface '#{opts[:surface]}':"
111
+ mode = opts[:pr] ? " (PR mode passing tests recorded as pr_passing)" : ''
112
+ puts "Ingested CI results for surface '#{opts[:surface]}'#{mode}:"
123
113
  totals[:reports].each do |report|
124
114
  report[:results].each do |r|
125
- glyph = r['status'] == 'passing' ? '✓' : (r['status'] == 'failing' ? '✗' : '•')
126
- puts " #{glyph} #{r['slug']} (#{r['status']}) #{r['source_file']}"
115
+ puts " #{Parity.glyph(r['status'])} #{r['slug']} (#{r['status']}) #{r['source_file']}"
127
116
  end
128
117
  end
129
118
  puts
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # Renders every requirement in a project as a single Markdown document —
6
+ # title, status, category, surfaces, why/acceptance criteria, and an
7
+ # evidence summary per surface — nested parent/child like `fp list`.
8
+ #
9
+ # Unlike `fp matrix --csv` (a parity spreadsheet) this is meant to be read
10
+ # top to bottom: handed to a stakeholder, dropped in a repo's docs/, or
11
+ # attached to a release as a point-in-time record of what the product is
12
+ # supposed to do.
13
+ class Export < Base
14
+ KNOWN_FLAGS = {
15
+ project: :string,
16
+ status: :string,
17
+ category: :string,
18
+ surface: :string,
19
+ output: :string
20
+ }.freeze
21
+
22
+ def run(args)
23
+ opts, = parse_flags(args, KNOWN_FLAGS)
24
+ require_project!(opts)
25
+
26
+ project_id = resolve_project_id(opts[:project])
27
+
28
+ result = client.list_requirements(project_id: project_id, category: opts[:category])
29
+ unless result[:ok]
30
+ output.error(result[:error], status: result[:status])
31
+ exit 1
32
+ end
33
+
34
+ requirements = result[:data]['requirements'] || []
35
+
36
+ status_filter = opts[:status]
37
+ requirements = requirements.select { |r| r['status'] == status_filter } if status_filter
38
+
39
+ if opts[:surface]
40
+ requirements = requirements.select { |r| (r['required_surfaces'] || []).include?(opts[:surface]) }
41
+ end
42
+
43
+ document = render_document(opts[:project], requirements, project_id)
44
+
45
+ if opts[:output]
46
+ File.write(opts[:output], document)
47
+ end
48
+
49
+ output.success({ project: opts[:project], requirement_count: requirements.length, document: document }) do
50
+ if opts[:output]
51
+ puts "Wrote #{requirements.length} requirement(s) to #{opts[:output]}"
52
+ else
53
+ puts document
54
+ end
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def render_document(project_slug, requirements, project_id)
61
+ lines = []
62
+ lines << "# Requirements: #{project_slug}"
63
+ lines << ''
64
+ lines << "_Generated #{Time.now.utc.strftime('%Y-%m-%d %H:%M UTC')} — #{requirements.length} requirement(s)._"
65
+ lines << ''
66
+
67
+ if requirements.empty?
68
+ lines << 'No requirements match the given filters.'
69
+ return lines.join("\n") + "\n"
70
+ end
71
+
72
+ parents = requirements.select { |r| r['parent_id'].nil? || r['parent_id'] == '' }
73
+ children_by_parent = requirements
74
+ .select { |r| r['parent_id'] && r['parent_id'] != '' }
75
+ .group_by { |r| r['parent_id'] }
76
+
77
+ parent_ids = parents.map { |p| p['id'] }.to_set
78
+ orphans = requirements.select { |r| r['parent_id'] && r['parent_id'] != '' && !parent_ids.include?(r['parent_id']) }
79
+
80
+ evidence_by_slug = fetch_evidence_by_slug(project_id, requirements)
81
+
82
+ parents.each do |req|
83
+ render_requirement(lines, req, evidence_by_slug[req['slug']], level: 2)
84
+ (children_by_parent[req['id']] || []).each do |child|
85
+ render_requirement(lines, child, evidence_by_slug[child['slug']], level: 3)
86
+ end
87
+ end
88
+
89
+ orphans.each { |req| render_requirement(lines, req, evidence_by_slug[req['slug']], level: 2) }
90
+
91
+ lines.join("\n") + "\n"
92
+ end
93
+
94
+ def render_requirement(lines, req, evidence, level:)
95
+ heading = '#' * level
96
+ lines << "#{heading} #{req['title']}"
97
+ lines << ''
98
+ lines << "- **Slug:** `#{req['slug']}`"
99
+ lines << "- **Status:** #{req['status']}"
100
+ lines << "- **Category:** #{req['category'] || '(none)'}"
101
+
102
+ surfaces = req['required_surfaces'] || []
103
+ lines << "- **Required Surfaces:** #{surfaces.empty? ? '(none)' : surfaces.join(', ')}"
104
+ lines << ''
105
+
106
+ if req['why'] && !req['why'].empty?
107
+ lines << '**Why:**'
108
+ lines << ''
109
+ lines << req['why']
110
+ lines << ''
111
+ end
112
+
113
+ if req['acceptance'] && !req['acceptance'].empty?
114
+ lines << '**Acceptance Criteria:**'
115
+ lines << ''
116
+ lines << req['acceptance']
117
+ lines << ''
118
+ end
119
+
120
+ lines << '**Evidence:**'
121
+ lines << ''
122
+ if evidence.nil? || evidence.empty?
123
+ lines << '_(none reported)_'
124
+ else
125
+ evidence.group_by { |e| e['surface'] }.each do |surface, records|
126
+ latest = records.max_by { |e| e['reported_at'].to_s }
127
+ lines << "- `#{surface}`: #{latest['status']}#{latest['reported_at'] ? " (as of #{latest['reported_at']})" : ''}"
128
+ end
129
+ end
130
+ lines << ''
131
+ end
132
+
133
+ # Evidence is keyed per-slug on the API, so fetch each requirement's records
134
+ # individually. Fine for a document export (infrequent, human-facing) rather
135
+ # than a hot path like `fp matrix`.
136
+ def fetch_evidence_by_slug(project_id, requirements)
137
+ requirements.each_with_object({}) do |req, memo|
138
+ result = client.list_evidence(project_id, slug: req['slug'])
139
+ memo[req['slug']] = result[:ok] ? (result[:data]['evidence'] || []) : []
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end