featureparity 0.0.16 → 0.0.18

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,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'set'
4
+ require 'open3'
4
5
 
5
6
  module Fp
6
7
  module Commands
@@ -34,8 +35,10 @@ module Fp
34
35
  # `fp report`.
35
36
  #
36
37
  # Multiple --junit flags may be passed to ingest several reports for the same
37
- # surface in one invocation (e.g. a suite split across shards). Each is
38
- # uploaded in turn.
38
+ # surface in one invocation (e.g. unit and e2e suites, or test shards). They are
39
+ # resolved and uploaded as one run. This is load-bearing: each API ingest performs
40
+ # the vanished-test sweep, so uploading partitions independently would make every
41
+ # partition call the passing tests in its siblings "missing".
39
42
  #
40
43
  # --base-dir tells the CLI where the report's relative test-file paths resolve from
41
44
  # (vitest, for instance, reports paths relative to the frontend package, not the repo
@@ -55,6 +58,8 @@ module Fp
55
58
  pr: :string,
56
59
  junit: :string,
57
60
  base_dir: :string,
61
+ repo: :string,
62
+ sha: :string,
58
63
  allow_empty: :boolean
59
64
  }.freeze
60
65
 
@@ -81,42 +86,64 @@ module Fp
81
86
 
82
87
  project_id = resolve_project_id(opts[:project])
83
88
  known = known_slugs(project_id)
89
+ commit_sha = resolve_commit_sha(opts)
90
+ repo = opts[:repo] || presence(ENV['GITHUB_REPOSITORY'])
84
91
 
85
92
  totals = { created: 0, updated: 0, missing_failing: 0, reports: [], resolved: 0,
86
- unmatched: 0, unknown: [], unmarked_failures: [] }
93
+ unmatched: 0, unknown: [], unmarked_failures: [], test_revisions: 0,
94
+ findings_opened: 0, baselines_accepted: 0 }
87
95
 
88
- junit_paths.each do |path|
89
- xml = File.read(path)
90
- resolved = resolve_results(path, opts)
96
+ resolved_reports = junit_paths.map do |path|
97
+ resolved = resolve_results(path, opts, commit_sha: commit_sha, repo: repo)
91
98
  totals[:unmatched] += resolved[:unmatched]
92
99
 
93
100
  results, unknown = partition_known(resolved[:results], known)
101
+ test_revisions, revision_unknown = partition_known(resolved[:test_revisions], known)
94
102
  totals[:resolved] += results.length
95
- totals[:unknown].concat(unknown)
103
+ totals[:unknown].concat(unknown).concat(revision_unknown)
96
104
  totals[:unmarked_failures].concat(resolved[:unmarked_failures])
105
+ totals[:test_revisions] += test_revisions.length
97
106
 
98
- result = client.ingest_ci_results(
99
- project_id,
100
- ci_url: opts[:ci_url],
101
- surface: opts[:surface],
102
- junit_xml: xml,
103
- pr_url: opts[:pr],
104
- results: results,
105
- unmarked_failures: resolved[:unmarked_failures]
106
- )
107
-
108
- unless result[:ok]
109
- output.error("Failed to ingest #{path}: #{result[:error]}", status: result[:status])
110
- exit 1
111
- end
107
+ { path: path, xml: File.read(path), results: results, test_revisions: test_revisions }
108
+ end
109
+
110
+ # One surface run may be split across several reports (unit + e2e, shards, etc.).
111
+ # The API's vanished-test sweep must see their union in one request; posting each
112
+ # partition separately makes the tests in every sibling report appear to have
113
+ # disappeared. Duplicate slug/surface/type claims are collapsed conservatively.
114
+ combined_results = combine_results(resolved_reports.flat_map { |report| report[:results] })
115
+ combined_revisions = combine_test_revisions(
116
+ resolved_reports.flat_map { |report| report[:test_revisions] }
117
+ )
118
+ result = client.ingest_ci_results(
119
+ project_id,
120
+ ci_url: opts[:ci_url],
121
+ surface: opts[:surface],
122
+ # Resolved results are authoritative. Keep one valid raw report for compatibility
123
+ # with servers old enough to ignore client-resolved results.
124
+ junit_xml: resolved_reports.first[:xml],
125
+ pr_url: opts[:pr],
126
+ results: combined_results,
127
+ test_revisions: combined_revisions,
128
+ unmarked_failures: totals[:unmarked_failures]
129
+ )
112
130
 
113
- data = result[:data] || {}
114
- totals[:created] += data['evidence_created'].to_i
115
- totals[:updated] += data['evidence_updated'].to_i
116
- totals[:missing_failing] += data['missing_marked_failing'].to_i
117
- totals[:reports] << { path: path, results: data['results'] || [] }
131
+ unless result[:ok]
132
+ output.error("Failed to ingest #{junit_paths.join(', ')}: #{result[:error]}", status: result[:status])
133
+ exit 1
118
134
  end
119
135
 
136
+ data = result[:data] || {}
137
+ # Belt's implicit instance-variable serializer wrapped this response in older
138
+ # deployments. Accept both shapes while the API now returns the result directly.
139
+ data = data['ingest_result'] if data['ingest_result'].is_a?(Hash)
140
+ totals[:created] += data['evidence_created'].to_i
141
+ totals[:updated] += data['evidence_updated'].to_i
142
+ totals[:missing_failing] += data['missing_marked_failing'].to_i
143
+ totals[:findings_opened] += data['changed_test_findings_opened'].to_i
144
+ totals[:baselines_accepted] += data['bound_test_baselines_accepted'].to_i
145
+ totals[:reports] << { paths: junit_paths, results: data['results'] || [] }
146
+
120
147
  # Reporting nothing is the failure this command exists to make visible: a run that
121
148
  # resolves no markers has told the matrix nothing, and used to do so silently.
122
149
  if totals[:resolved].zero? && !opts[:allow_empty]
@@ -194,6 +221,29 @@ module Fp
194
221
  (result[:data]['requirements'] || []).map { |r| r['slug'] }.compact.to_set
195
222
  end
196
223
 
224
+ # Collapse the same marker key across report partitions before uploading it. A red
225
+ # claim wins; otherwise any executed passing test wins over an all-skipped stub.
226
+ # Counts are summed so the evidence still describes the complete surface run, and
227
+ # the source points at the claim that determined the verdict.
228
+ def combine_results(results)
229
+ severity = { 'failing' => 0, 'passing' => 1, 'stub' => 2 }
230
+
231
+ results.each_with_object({}) do |result, combined|
232
+ key = [result[:slug], result[:surface], result[:test_type]]
233
+ current = combined[key]
234
+ unless current
235
+ combined[key] = result.dup
236
+ next
237
+ end
238
+
239
+ decisive = [current, result].min_by { |entry| severity.fetch(entry[:status], 0) }
240
+ merged = decisive.dup
241
+ merged[:test_count] = current[:test_count].to_i + result[:test_count].to_i
242
+ merged[:failure_count] = current[:failure_count].to_i + result[:failure_count].to_i
243
+ combined[key] = merged
244
+ end.values
245
+ end
246
+
197
247
  def partition_known(results, known)
198
248
  return [results, []] if known.nil?
199
249
 
@@ -201,6 +251,15 @@ module Fp
201
251
  [kept, unknown.map { |r| r[:slug] }.uniq]
202
252
  end
203
253
 
254
+ # Reports can be split into shards. Keep every independently identified testcase,
255
+ # but collapse an exact duplicate emitted by overlapping reports.
256
+ def combine_test_revisions(revisions)
257
+ revisions.each_with_object({}) do |revision, combined|
258
+ key = [revision[:slug], revision[:surface], revision[:test_type], revision[:testcase_key]]
259
+ combined[key] = revision
260
+ end.values
261
+ end
262
+
204
263
  # A warning that survives a CI log. Stderr always; a GitHub Actions annotation too
205
264
  # when we're running in one, so it shows up in the run summary rather than scrolling
206
265
  # past in a collapsed step.
@@ -210,13 +269,15 @@ module Fp
210
269
  end
211
270
 
212
271
  # Bind this report's testcases to their fp markers, locally, in the checkout.
213
- def resolve_results(path, opts)
272
+ def resolve_results(path, opts, commit_sha:, repo:)
214
273
  testcases = Fp::JUnit.parse_file(path)
215
274
  resolved = Fp::JUnit.ci_results(
216
275
  testcases,
217
276
  base_dir: opts[:base_dir] || Dir.pwd,
218
277
  default_surface: opts[:surface],
219
- default_test_type: opts[:test_type]
278
+ default_test_type: opts[:test_type],
279
+ commit_sha: commit_sha,
280
+ repo: repo
220
281
  )
221
282
 
222
283
  {
@@ -233,6 +294,25 @@ module Fp
233
294
  failure_count: r.failure_count
234
295
  }
235
296
  end,
297
+ test_revisions: resolved[:test_revisions].map do |revision|
298
+ {
299
+ slug: revision.slug,
300
+ surface: revision.surface,
301
+ test_type: revision.test_type,
302
+ testcase_key: revision.testcase_key,
303
+ testcase_name: revision.testcase_name,
304
+ classname: revision.classname,
305
+ outcome: revision.outcome,
306
+ repo: revision.repo,
307
+ source_file: revision.file,
308
+ marker_line: revision.marker_line,
309
+ source_start_line: revision.start_line,
310
+ source_end_line: revision.end_line,
311
+ commit_sha: revision.commit_sha,
312
+ fingerprint: revision.fingerprint,
313
+ fingerprint_scope: revision.fingerprint_scope
314
+ }
315
+ end,
236
316
  unmatched: resolved[:unmatched].length,
237
317
  # Loose ends: failing tests with no fp:<slug> marker. Shipped to the server so a
238
318
  # human can see them on the matrix; a red test nobody tracks against a
@@ -251,6 +331,27 @@ module Fp
251
331
  exit 1
252
332
  end
253
333
 
334
+ def resolve_commit_sha(opts)
335
+ explicit = presence(opts[:sha])
336
+ return explicit if explicit
337
+
338
+ root = Fp::JUnit.repo_root_for(opts[:base_dir] || Dir.pwd)
339
+ stdout, _stderr, status = Open3.capture3('git', 'rev-parse', 'HEAD', chdir: root || Dir.pwd)
340
+ sha = presence(stdout)
341
+ return sha if status.success? && sha
342
+
343
+ ci_sha = presence(ENV['GITHUB_SHA'])
344
+ return ci_sha if ci_sha
345
+
346
+ output.error('Could not determine the tested commit. Pass --sha <commit>.')
347
+ exit 1
348
+ end
349
+
350
+ def presence(value)
351
+ text = value.to_s.strip
352
+ text.empty? ? nil : text
353
+ end
354
+
254
355
  # Pull the human-readable summary together. `markers_resolved` is worth printing
255
356
  # even when everything worked: it's the number that used to silently be zero.
256
357
  def emit_summary(opts, totals)
@@ -265,6 +366,9 @@ module Fp
265
366
  markers_resolved: totals[:resolved],
266
367
  unmatched_testcases: totals[:unmatched],
267
368
  unmarked_failures: totals[:unmarked_failures],
369
+ test_revisions_reported: totals[:test_revisions],
370
+ changed_test_findings_opened: totals[:findings_opened],
371
+ bound_test_baselines_accepted: totals[:baselines_accepted],
268
372
  skipped_unknown: totals[:unknown].uniq,
269
373
  reports: totals[:reports]
270
374
  }
@@ -288,6 +392,7 @@ module Fp
288
392
  puts " loose ends: #{totals[:unmarked_failures].length} failing test(s) with no " \
289
393
  'fp:<slug> marker (shown on the matrix, not a gate failure)'
290
394
  end
395
+ puts " source receipts: #{totals[:test_revisions]} changed-test findings opened: #{totals[:findings_opened]}"
291
396
  puts " created: #{totals[:created]} updated: #{totals[:updated]} marked failing (missing): #{totals[:missing_failing]}"
292
397
  end
293
398
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ module Commands
5
+ # `fp comment <slug> --body "..."` — thread a comment onto a requirement's open
6
+ # change proposal.
7
+ #
8
+ # The single-open-proposal rule stops a second agent opening a rival change (two
9
+ # "what it was" snapshots would make a rollback ambiguous). This is the way out of
10
+ # that dead end: instead of a rival proposal, an agent states its case *on* the
11
+ # proposal a human is about to review. The comment is append-only — it never touches
12
+ # the requirement's status or the proposal's snapshot — so the invariant the 409
13
+ # protects stays intact.
14
+ class Comment < Base
15
+ KNOWN_FLAGS = {
16
+ project: :string, body: :string, change: :string,
17
+ source_type: :string, source_url: :string, source_context: :string
18
+ }.freeze
19
+
20
+ VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
21
+
22
+ def run(args)
23
+ opts, positional = parse_flags(args, KNOWN_FLAGS)
24
+ require_project!(opts)
25
+
26
+ slug = positional.first
27
+ unless slug
28
+ output.error('Requirement slug is required. Usage: fp comment <slug> --body "..." [--project <project>]')
29
+ exit 1
30
+ end
31
+
32
+ body = opts[:body]
33
+ if body.nil? || body.strip.empty?
34
+ output.error('A comment body is required. Pass --body "...".')
35
+ exit 1
36
+ end
37
+
38
+ validate_source_type!(opts)
39
+
40
+ project_id = resolve_project_id(opts[:project])
41
+ requirement = find_requirement!(project_id, slug)
42
+
43
+ change_id = opts[:change] || requirement['pending_change_id']
44
+ if change_id.nil? || change_id.to_s.empty?
45
+ output.error("Requirement '#{slug}' has no open proposal to comment on.")
46
+ exit 1
47
+ end
48
+
49
+ result = client.comment_requirement_change(requirement['id'], change_id, build_params(opts))
50
+ unless result[:ok]
51
+ output.error(result[:error], status: result[:status])
52
+ exit 1
53
+ end
54
+
55
+ print_result(result[:data], slug: slug)
56
+ end
57
+
58
+ private
59
+
60
+ def validate_source_type!(opts)
61
+ return unless opts[:source_type] && !VALID_SOURCE_TYPES.include?(opts[:source_type])
62
+
63
+ output.error("Invalid source type '#{opts[:source_type]}'. Must be one of: #{VALID_SOURCE_TYPES.join(', ')}")
64
+ exit 1
65
+ end
66
+
67
+ def find_requirement!(project_id, slug)
68
+ result = client.find_requirement_by_slug(project_id, slug)
69
+ unless result[:ok]
70
+ output.error(result[:error], status: result[:status])
71
+ exit 1
72
+ end
73
+ result[:data]['requirement']
74
+ end
75
+
76
+ def build_params(opts)
77
+ params = { body: opts[:body] }
78
+ params[:source_type] = opts[:source_type] if opts[:source_type]
79
+ params[:source_url] = opts[:source_url] if opts[:source_url]
80
+ params[:source_context] = opts[:source_context] if opts[:source_context]
81
+ params
82
+ end
83
+
84
+ def print_result(data, slug:)
85
+ change = data['change'] || {}
86
+
87
+ output.success(data) do
88
+ puts "Commented on the open proposal for '#{slug}'."
89
+ puts
90
+ puts " Comment: #{change['body']}"
91
+ puts " On: #{change['proposal_id']}"
92
+ puts
93
+ puts 'A human reviewing the proposal will see this comment in the history.'
94
+ puts "Run `fp history #{slug} --project <project>` to see the thread."
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -22,7 +22,9 @@ module Fp
22
22
  propose --project <slug> ... Propose a new requirement (as draft)
23
23
  propose-change <slug> ... Propose a change to an existing requirement
24
24
  propose-change <slug> --removal Propose deleting a requirement
25
+ comment <slug> --body "..." Comment on a requirement's open change proposal
25
26
  history <slug> --project <slug> Show a requirement's change history + diffs
27
+ analyze [<slug>] --project <slug> Triage requirement quality (read-only; never proposes)
26
28
  changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
27
29
  attach <slug> <file> Upload a file attachment to a requirement
28
30
  attachments <slug> List attachments for a requirement
@@ -33,6 +35,8 @@ module Fp
33
35
  report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
34
36
  ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
35
37
  ci-report --junit <file> --pr URL Ingest a PR's CI results as pr_passing — CI only
38
+ changed-tests List unresolved bound-test revision findings
39
+ changed-tests assess <id> ... Explain whether an exact revision still proves its requirement
36
40
  check Enforce parity; exits non-zero on violations
37
41
  matrix --project <slug> Show the parity matrix
38
42
  export --project <slug> Export all requirements as a Markdown document
@@ -77,6 +81,24 @@ module Fp
77
81
  merged-green truth. Run ci-report *without* --pr on a push to the default branch to
78
82
  record the real `passing`, which supersedes any pr_passing a prior PR left behind.
79
83
 
84
+ CHANGED BOUND TESTS
85
+ `fp ci-report` fingerprints every bound testcase at the checkout commit. It uses
86
+ an isolated-test SHA-256 when the source boundary is reliable and labels a whole-file
87
+ fallback otherwise. A different digest opens an audit finding without turning a green
88
+ test red or replacing the accepted baseline.
89
+
90
+ fp changed-tests
91
+ fp changed-tests --json
92
+ fp changed-tests assess <finding-id> \
93
+ --candidate-fingerprint <sha256> \
94
+ --classification still_proves \
95
+ --explanation "The setup changed; the same observable result is asserted."
96
+
97
+ Classifications are still_proves, no_longer_proves, and uncertain. Only still_proves
98
+ resolves that exact fingerprint; the other two remain open for a human product decision.
99
+ A stale or later candidate needs a new assessment. A PR candidate becomes accepted only
100
+ when a default-branch CI report later observes the same fingerprint.
101
+
80
102
  AUTHENTICATION
81
103
  Set FP_API_KEY environment variable, or use a profile:
82
104
 
@@ -175,6 +197,18 @@ module Fp
175
197
  # which promise reflects product intent and record that decision with propose-change
176
198
  # (or --removal). Never weaken the test or add a requirement around the conflict;
177
199
  # ask a human if intent is unclear.
200
+ # Existing tests are protected product evidence. Flag any edit to a test backing
201
+ # passing evidence and get explicit human approval before changing its expected
202
+ # behaviour. When FeatureParity reports a changed bound test, run `fp changed-tests`
203
+ # (or add `--json`), compare the accepted and candidate revisions with the requirement,
204
+ # then assess the exact candidate fingerprint with a plain-language explanation as
205
+ # `still_proves`, `no_longer_proves`, or `uncertain`. Only still_proves resolves that
206
+ # fingerprint; possible behaviour changes remain open for a human decision, and a later
207
+ # digest needs a new assessment. This audit finding is separate from the CI outcome and
208
+ # from `suspect`, which means the requirement wording changed.
209
+ # A new requirement — draft or active — does not supersede a pre-existing failing test;
210
+ # surface the conflict and do not change or adopt that test as proof without explicit
211
+ # human approval.
178
212
  #
179
213
  # Write the requirement as a product decision someone can approve, not a design
180
214
  # task. It should sound like a capable teammate, not AI boilerplate.
@@ -234,11 +268,38 @@ module Fp
234
268
  fp history print_container_qr --project stowzilla
235
269
  fp history print_container_qr --project stowzilla --pending
236
270
 
237
- # Upload a file attachment to a requirement (screenshot evidence, docs, etc.)
238
- # Supported formats: png, jpg, gif, webp, svg, pdf, txt, md (max 10 MB)
239
- fp attach print_container_qr screenshot.png --project stowzilla
271
+ # Triage the catalog's quality: duplicates, fragments, vague wording, conflicts.
272
+ # One model call per uncached requirement; unchanged ones are read back free.
273
+ fp analyze --project stowzilla
274
+ fp analyze print_container_qr --project stowzilla --conflicts
275
+
276
+ # Upload visual test evidence to a requirement. Images and videos require
277
+ # --surface; deployment fields let reviewers verify exactly what build the
278
+ # capture observed. --example-title names the individual test the media
279
+ # proves, so it shows next to that test's receipt instead of a surface-wide
280
+ # gallery. --evidence-key is a stronger join: it directly names the receipt
281
+ # by its composite key (surface:repo:test_type) so the match survives title
282
+ # renames.
283
+ #
284
+ # Visual formats: png, jpg, gif, webp, mp4, webm, mov
285
+ # Text formats: txt, md, json (render inline as code blocks for API evidence)
286
+ # Documents: svg, pdf (downloadable)
287
+ # Max 100 MB.
288
+ fp attach print_container_qr e2e-run.webm --project stowzilla \\
289
+ --surface customer_web \\
290
+ --example-title "prints a QR code onto the container label" \\
291
+ --captured-at 2026-10-15T01:42:00Z \\
292
+ --deployed-at 2026-10-15T01:30:00Z \\
293
+ --sha abc1234 \\
294
+ --deployment-url https://preview.example.test \\
295
+ --test-run-url https://github.com/stowzilla/app/actions/runs/123
296
+
297
+ # For API tests, attach the JSON response as inline evidence
298
+ fp attach api_returns_order_details response.json --project stowzilla \\
299
+ --surface api \\
300
+ --evidence-key "api:stowzilla/api:e2e"
240
301
 
241
- # List attachments for a requirement
302
+ # List attachments and their capture/deployment context
242
303
  fp attachments print_container_qr --project stowzilla
243
304
 
244
305
  # Get a download URL for a specific attachment
@@ -180,17 +180,19 @@ module Fp
180
180
  end
181
181
  end
182
182
 
183
- # The CI gate. Two jobs on purpose: reporting evidence and enforcing the gate are
184
- # separate concerns, and the gate must run on pull requests (where it blocks a
185
- # merge) while evidence reporting only makes sense on trusted pushes.
183
+ # The CI gate and its adaptable reporting recipe are separate concerns: the gate
184
+ # blocks pull requests, while reporting needs a trusted context with FP_API_KEY and
185
+ # should cover both pull requests and default-branch pushes.
186
186
  def workflow_body
187
187
  <<~YAML
188
188
  # FeatureParity — parity enforcement.
189
189
  #
190
- # Generated by `fp init`. Two jobs, deliberately separate:
190
+ # Generated by `fp init`. The gate is ready to use; adapt and uncomment the
191
+ # report recipe once this repository emits JUnit:
191
192
  #
192
- # report On pushes to the default branch, feed test results back to
193
- # FeatureParity so the parity matrix reflects real pass/fail.
193
+ # report On trusted pull requests, report candidate test revisions before
194
+ # merge. On default-branch pushes, record merged pass/fail and promote
195
+ # an approved exact test revision into the accepted baseline.
194
196
  # gate On pull requests, fail the build when the project violates its
195
197
  # enforcement level. Make this a required status check in branch
196
198
  # protection to actually block merges.
@@ -261,30 +263,44 @@ module Fp
261
263
  run: fp check
262
264
 
263
265
  # Uncomment and adapt once your suite emits JUnit XML. `fp ci-report` writes
264
- # real passing/failing evidence — without it, the matrix only ever shows the
265
- # `present` markers agents filed, and the gate has nothing green to see.
266
+ # real passing/failing evidence and one source fingerprint per bound testcase.
267
+ # Run it on trusted pull requests too: `--pr` lets FeatureParity open a changed-test
268
+ # finding before merge without replacing the accepted default-branch baseline.
269
+ # The later default-branch report records merged truth and promotes an approved exact
270
+ # fingerprint. Without reporting, the matrix only has agent-filed `present` markers
271
+ # and changed bound tests cannot be detected.
266
272
  #
267
273
  # It reads the fp:<slug> markers out of the checkout, so run it in the repo
268
274
  # and pass --base-dir when the report's test-file paths are relative to a
269
275
  # subdirectory (vitest, for instance, reports them relative to its package).
276
+ # Keep `if: always()` so red tests are reported. Fork pull requests cannot read
277
+ # FP_API_KEY and are skipped by the HAVE_FP_KEY guard.
270
278
  #
271
279
  # report:
272
280
  # name: Report evidence
273
- # if: github.event_name == 'push'
281
+ # if: github.event_name == 'push' || github.event_name == 'pull_request'
274
282
  # runs-on: ubuntu-latest
283
+ # env:
284
+ # HAVE_FP_KEY: ${{ secrets.FP_API_KEY != '' }}
275
285
  # steps:
276
286
  # - uses: actions/checkout@v4
277
287
  # - name: Run tests (emit JUnit)
278
288
  # run: echo "replace with your test command emitting junit.xml"
279
289
  # - name: Report to FeatureParity
280
- # if: always()
290
+ # if: always() && env.HAVE_FP_KEY == 'true'
281
291
  # env:
282
292
  # FP_API_KEY: ${{ secrets.FP_API_KEY }}
283
293
  # run: |
284
294
  # gem install featureparity
285
- # fp ci-report --junit junit.xml \\
286
- # --surface <surface-key> \\
295
+ # args=(
296
+ # --junit junit.xml
297
+ # --surface <surface-key>
287
298
  # --ci-url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
299
+ # )
300
+ # if [ "${{ github.event_name }}" = "pull_request" ]; then
301
+ # args+=(--pr "${{ github.event.pull_request.html_url }}")
302
+ # fi
303
+ # fp ci-report "${args[@]}"
288
304
  YAML
289
305
  end
290
306
 
@@ -361,6 +377,30 @@ module Fp
361
377
  `fp propose-change` (or `--removal`) with the source and reason to put that decision before
362
378
  a human. If the intent is unclear, stop and ask rather than making the catalog say both things.
363
379
 
380
+ **Treat existing tests as protected product evidence.** Before editing an existing test,
381
+ determine what expectation it already records and whether CI has passing evidence for it.
382
+
383
+ - If the test backs passing evidence, do not rewrite it to make the current implementation
384
+ or a new requirement fit. Explicitly flag the intended test edit in the work item and pull
385
+ request. If its expected behaviour would change, propose the requirement change and wait
386
+ for explicit human approval before editing the test; a newly green run must not hide that
387
+ the proof itself changed.
388
+ - When FeatureParity reports a changed bound test, run `fp changed-tests` (or add `--json`)
389
+ and compare the accepted and candidate source revisions with the requirement. Record the
390
+ exact candidate through `fp changed-tests assess <id> --candidate-fingerprint <sha256>`
391
+ with a plain-language explanation and one classification: `still_proves`,
392
+ `no_longer_proves`, or `uncertain`. The first resolves only that fingerprint; the others
393
+ remain open for a human product decision. A later fingerprint needs a new assessment.
394
+ - A new requirement does not retroactively own or supersede a pre-existing failing test —
395
+ whether the requirement is draft or active. Do not change that test, or adopt it as the new
396
+ requirement's proof, merely to make the new work pass. Surface the conflict and wait for
397
+ explicit human approval to change the existing expectation.
398
+
399
+ A changed-test finding is an audit signal alongside the testcase's independent CI outcome.
400
+ It is not a failed test and is separate from `suspect`, which means requirement wording
401
+ changed and its evidence needs re-verification. Neither permits rewriting a test before
402
+ product intent is decided.
403
+
364
404
  If no requirement covers what you're building, propose one or a parent/child tree. Agents
365
405
  always create **drafts**; a human activates them:
366
406
 
@@ -55,7 +55,11 @@ module Fp
55
55
 
56
56
  result = client.propose_requirement_change(requirement['id'], params)
57
57
  unless result[:ok]
58
- output.error(result[:error], status: result[:status])
58
+ if result[:status] == 409 && result[:details].is_a?(Hash)
59
+ print_conflict(result, slug: slug)
60
+ else
61
+ output.error(result[:error], status: result[:status])
62
+ end
59
63
  exit 1
60
64
  end
61
65
 
@@ -64,6 +68,47 @@ module Fp
64
68
 
65
69
  private
66
70
 
71
+ # A 409 means the requirement already has an open proposal — the one-at-a-time rule
72
+ # (two "what it was" snapshots would make a rollback ambiguous). Rather than a dead
73
+ # end, show what the open proposal is and point at `fp comment` so the agent can
74
+ # raise its concern on the proposal a human is about to review.
75
+ def print_conflict(result, slug:)
76
+ pending = result[:details]['pending_change'] || {}
77
+
78
+ if output.json?
79
+ output.error(result[:error], status: result[:status])
80
+ return
81
+ end
82
+
83
+ warn "Error: #{result[:error]}"
84
+ warn ''
85
+ warn 'The open proposal:'
86
+ warn " Change: #{pending['id']}"
87
+ warn " Kind: #{pending['kind']}"
88
+ warn " Proposed: #{pending['actor']} (#{pending['actor_kind']})"
89
+ warn " Reason: #{pending['reason']}" if pending['reason']
90
+ print_conflict_source(pending['source'])
91
+ print_conflict_diff(pending['changes'])
92
+ warn ''
93
+ warn 'You cannot open a rival proposal, but you can comment on this one:'
94
+ warn " fp comment #{slug} --change #{pending['id']} --body \"...\" --project <project>"
95
+ warn 'Or wait for a human to review it. Run `fp history ' \
96
+ "#{slug} --project <project>` for the full diff."
97
+ end
98
+
99
+ def print_conflict_source(source)
100
+ return unless source.is_a?(Hash) && !source.empty?
101
+
102
+ warn " Source: #{source['type']}" if source['type']
103
+ warn " URL: #{source['url']}" if source['url']
104
+ end
105
+
106
+ def print_conflict_diff(changes)
107
+ return unless changes.is_a?(Hash) && !changes.empty?
108
+
109
+ warn ' Fields: ' + changes.keys.join(', ')
110
+ end
111
+
67
112
  def validate_enums!(opts)
68
113
  if opts[:category] && !VALID_CATEGORIES.include?(opts[:category])
69
114
  output.error("Invalid category '#{opts[:category]}'. Must be one of: #{VALID_CATEGORIES.join(', ')}")