featureparity 0.0.15 → 0.0.17

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.
@@ -115,9 +115,26 @@ module Fp
115
115
  exit 1
116
116
  end
117
117
 
118
+ # The server echoes back the stored record, whose status/source may differ
119
+ # from what we asked for: CI evidence is authoritative, so an agent's
120
+ # present/stub never downgrades a cell CI already verified. Report the truth
121
+ # the server kept, not the state we requested, so the caller knows whether
122
+ # their report actually landed or was superseded by CI.
123
+ stored = result[:data].is_a?(Hash) ? (result[:data]['evidence'] || {}) : {}
124
+ stored_status = stored['status']
125
+ stored_source = stored['source']
126
+ superseded = ci_superseded?(state, stored_status, stored_source)
127
+
118
128
  output.success(result[:data]) do
119
- puts "Evidence reported for '#{slug}' on surface '#{opts[:surface]}'."
120
- puts " State: #{state}"
129
+ if superseded
130
+ puts "CI already verified '#{slug}' on surface '#{opts[:surface]}' (status: #{stored_status})."
131
+ puts "Your '#{state}' report was recorded as metadata but did NOT change the status —"
132
+ puts 'CI evidence is authoritative and is never downgraded by an agent report.'
133
+ puts ' Stored: ' + "#{stored_status} (source: #{stored_source})"
134
+ else
135
+ puts "Evidence reported for '#{slug}' on surface '#{opts[:surface]}'."
136
+ puts " State: #{stored_status || state}"
137
+ end
121
138
  puts " Type: #{opts[:test_type]}" if opts[:test_type]
122
139
  puts " File: #{opts[:file]}#{opts[:line] ? ":#{opts[:line]}" : ''}"
123
140
  if opts[:pr]
@@ -140,6 +157,20 @@ module Fp
140
157
 
141
158
  private
142
159
 
160
+ # CI statuses an agent can never set. Mirrors Evidence::CI_STATUSES on the
161
+ # server — kept here so the CLI can recognise, from the echoed record, when its
162
+ # present/stub report was superseded by an authoritative CI verdict.
163
+ CI_STATUSES = %w[passing pr_passing failing].freeze
164
+
165
+ # True when the server kept a CI verdict instead of applying our report: the
166
+ # stored evidence is CI-sourced with a CI status, yet we asked for present/stub.
167
+ def ci_superseded?(requested_state, stored_status, stored_source)
168
+ return false if stored_status.nil?
169
+ return false unless VALID_STATES.include?(requested_state)
170
+
171
+ stored_source == 'ci' && CI_STATUSES.include?(stored_status) && stored_status != requested_state
172
+ end
173
+
143
174
  # Batch-upload evidence from a JUnit XML report.
144
175
  def run_junit(opts)
145
176
  require_project!(opts)
@@ -244,16 +275,34 @@ module Fp
244
275
  exit 1
245
276
  end
246
277
 
247
- reported << { binding: binding, state: state, recorded: true }
278
+ reported << { binding: binding, state: state, recorded: true,
279
+ stored_status: stored_status_from(report_result),
280
+ stored_source: stored_source_from(report_result) }
248
281
  end
249
282
 
250
283
  emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
251
284
  end
252
285
 
286
+ # Pull the stored status/source out of the server's echoed evidence record so the
287
+ # batch summary can flag cells where CI superseded the agent's present/stub.
288
+ def stored_status_from(report_result)
289
+ data = report_result[:data]
290
+ return nil unless data.is_a?(Hash)
291
+
292
+ (data['evidence'] || {})['status']
293
+ end
294
+
295
+ def stored_source_from(report_result)
296
+ data = report_result[:data]
297
+ return nil unless data.is_a?(Hash)
298
+
299
+ (data['evidence'] || {})['source']
300
+ end
301
+
253
302
  def emit_junit_summary(opts, reported, skipped_unknown, unmatched, api_unavailable)
254
303
  data = {
255
304
  repo: opts[:repo],
256
- reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, test_type: r[:binding].test_type, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded] } },
305
+ reported: reported.map { |r| { slug: r[:binding].slug, surface: r[:binding].surface, test_type: r[:binding].test_type, file: r[:binding].file, line: r[:binding].line, state: r[:state], recorded: r[:recorded], stored_status: r[:stored_status], superseded_by_ci: ci_superseded?(r[:state], r[:stored_status], r[:stored_source]) } },
257
306
  skipped_unknown: skipped_unknown.map { |b| { slug: b.slug, surface: b.surface, file: b.file } },
258
307
  unmatched_testcases: unmatched.map { |tc| { name: tc.name, file: tc.file } }
259
308
  }
@@ -261,10 +310,18 @@ module Fp
261
310
  output.success(data) do
262
311
  puts "Reported evidence from #{opts[:junit]}:"
263
312
  reported.each do |r|
264
- marker = r[:recorded] ? '✓' : '•'
313
+ superseded = ci_superseded?(r[:state], r[:stored_status], r[:stored_source])
314
+ marker = if !r[:recorded]
315
+ '•'
316
+ elsif superseded
317
+ '⊘'
318
+ else
319
+ '✓'
320
+ end
265
321
  loc = r[:binding].line ? "#{r[:binding].file}:#{r[:binding].line}" : r[:binding].file
266
322
  type = r[:binding].test_type ? " [#{r[:binding].test_type}]" : ''
267
- puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface}#{type} (#{r[:state]}) #{loc}"
323
+ shown_state = superseded ? "#{r[:stored_status]}, kept CI — #{r[:state]} ignored" : r[:state]
324
+ puts " #{marker} #{r[:binding].slug} → #{r[:binding].surface}#{type} (#{shown_state}) #{loc}"
268
325
  end
269
326
 
270
327
  unless skipped_unknown.empty?
@@ -24,6 +24,7 @@ module Fp
24
24
  end
25
25
 
26
26
  requirement = result[:data]['requirement']
27
+ source_history = result[:data]['source_history'] || []
27
28
 
28
29
  # Evidence is not embedded in the requirement payload — it lives on the project's
29
30
  # evidence collection, keyed by slug. Fetch it, or `fp show` reports "(none)" for
@@ -37,7 +38,7 @@ module Fp
37
38
  payload['parent_slug'] = parent_slug if parent_slug
38
39
 
39
40
  output.success(payload) do
40
- print_requirement(requirement, evidence: evidence, parent_slug: parent_slug)
41
+ print_requirement(requirement, evidence: evidence, parent_slug: parent_slug, source_history: source_history)
41
42
  end
42
43
  end
43
44
 
@@ -60,7 +61,7 @@ module Fp
60
61
  parent && parent['slug']
61
62
  end
62
63
 
63
- def print_requirement(req, evidence: [], parent_slug: nil)
64
+ def print_requirement(req, evidence: [], parent_slug: nil, source_history: [])
64
65
  puts "#{req['title']}"
65
66
  puts "=" * req['title'].length
66
67
  puts
@@ -106,6 +107,23 @@ module Fp
106
107
  puts
107
108
  end
108
109
 
110
+ # Source trail: every source (Fizzy card, Discord thread, issue) that prompted a
111
+ # change to this requirement, oldest first. Derived from events so the history is
112
+ # additive — older sources aren't lost when a new one is recorded.
113
+ puts 'Source Trail:'
114
+ if source_history.empty?
115
+ puts ' (no sources recorded)'
116
+ else
117
+ source_history.each do |src|
118
+ line = []
119
+ line << src['type'] if src['type']
120
+ line << src['url'] if src['url']
121
+ puts " - #{line.join(' ')}"
122
+ puts " #{src['context']}" if src['context']
123
+ end
124
+ end
125
+ puts
126
+
109
127
  # Evidence receipts, newest first, grouped per surface.
110
128
  puts 'Evidence:'
111
129
  if evidence.empty?
data/lib/fp/commands.rb CHANGED
@@ -7,9 +7,12 @@ require_relative 'commands/list'
7
7
  require_relative 'commands/show'
8
8
  require_relative 'commands/propose'
9
9
  require_relative 'commands/propose_change'
10
+ require_relative 'commands/comment'
10
11
  require_relative 'commands/history'
12
+ require_relative 'commands/changes'
11
13
  require_relative 'commands/report'
12
14
  require_relative 'commands/ci_report'
15
+ require_relative 'commands/changed_tests'
13
16
  require_relative 'commands/check'
14
17
  require_relative 'commands/init'
15
18
  require_relative 'commands/matrix'
data/lib/fp/junit.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'rexml/document'
4
+ require_relative 'source_fingerprint'
4
5
 
5
6
  module Fp
6
7
  # Parses JUnit XML reports and binds testcases to fp:<slug> markers found in
@@ -40,6 +41,15 @@ module Fp
40
41
  CiResult = Struct.new(:slug, :surface, :test_type, :status, :file, :line, :title, :test_count,
41
42
  :failure_count, keyword_init: true)
42
43
 
44
+ # One exact testcase source receipt. Unlike CiResult, these are never aggregated:
45
+ # two tests may back the same requirement cell and each can change independently.
46
+ TestRevision = Struct.new(
47
+ :slug, :surface, :test_type, :testcase_key, :testcase_name, :classname, :outcome,
48
+ :repo, :file, :marker_line, :start_line, :end_line, :commit_sha,
49
+ :fingerprint, :fingerprint_scope,
50
+ keyword_init: true
51
+ )
52
+
43
53
  # A failing testcase that carries no fp:<slug> marker at all — a "loose end". Not a
44
54
  # cell on the parity grid (nothing binds it to a requirement) and not an orphan
45
55
  # marker (there is no marker); it's a red test nobody is tracking against a
@@ -58,7 +68,7 @@ module Fp
58
68
  # directory the runner was invoked from (vitest reports `src/pages/Foo.test.jsx` from
59
69
  # inside `frontend/`), so reporting it verbatim produces a link to a file that doesn't
60
70
  # exist at that path in the repo.
61
- Match = Struct.new(:testcase, :marker, :precise, :repo_file, keyword_init: true)
71
+ Match = Struct.new(:testcase, :marker, :precise, :repo_file, :source_path, keyword_init: true)
62
72
 
63
73
  # Evidence discovered by binding a testcase to a marker.
64
74
  # `line` is the 1-based line number of the test in its source file: the
@@ -234,9 +244,13 @@ module Fp
234
244
  # A slug is `failing` if any test claiming it went red, `stub` if every test claiming
235
245
  # it was skipped (a skipped test is not proof of anything), and `passing` otherwise.
236
246
  #
237
- # Returns { results: [CiResult, ...], unmatched: [TestCase], missing_surface: [...] }.
238
- def ci_results(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil)
247
+ # Returns { results: [CiResult, ...], test_revisions: [TestRevision, ...],
248
+ # unmatched: [TestCase], missing_surface: [...] }.
249
+ def ci_results(testcases, base_dir: Dir.pwd, default_surface: nil, default_test_type: nil,
250
+ commit_sha: nil, repo: nil)
239
251
  aggregates = {}
252
+ test_revisions = []
253
+ fingerprint_cache = {}
240
254
  matched = {}
241
255
  missing_surface = []
242
256
 
@@ -251,6 +265,8 @@ module Fp
251
265
  # The marker's own type pin wins; otherwise the --test-type default (nil → the
252
266
  # server files it as `unspecified`). One CI verdict per (slug, surface, type).
253
267
  test_type = marker[:test_type] || default_test_type
268
+ fingerprint = fingerprint_cache[[match.source_path, marker[:line]]] ||=
269
+ SourceFingerprint.build(match.source_path, marker)
254
270
 
255
271
  surfaces.each do |surface|
256
272
  if surface.nil? || surface.empty?
@@ -276,6 +292,18 @@ module Fp
276
292
  agg[:title] ||= title
277
293
  agg[:failing_title] ||= title if tc.failed?
278
294
  end
295
+
296
+ if fingerprint && commit_sha
297
+ outcome = if tc.stub? then 'stub' elsif tc.failed? then 'failing' else 'passing' end
298
+ test_revisions << TestRevision.new(
299
+ slug: marker[:slug], surface: surface, test_type: test_type,
300
+ testcase_key: testcase_key(match.repo_file, tc), testcase_name: tc.name,
301
+ classname: tc.classname, outcome: outcome, repo: repo, file: match.repo_file,
302
+ marker_line: marker[:line], start_line: fingerprint.start_line,
303
+ end_line: fingerprint.end_line, commit_sha: commit_sha,
304
+ fingerprint: fingerprint.fingerprint, fingerprint_scope: fingerprint.scope
305
+ )
306
+ end
279
307
  end
280
308
  end
281
309
 
@@ -293,7 +321,12 @@ module Fp
293
321
  end
294
322
 
295
323
  unmatched = testcases.reject { |tc| matched[tc.object_id] }
296
- { results: results, unmatched: unmatched, missing_surface: missing_surface.uniq }
324
+ {
325
+ results: results,
326
+ test_revisions: test_revisions,
327
+ unmatched: unmatched,
328
+ missing_surface: missing_surface.uniq
329
+ }
297
330
  end
298
331
 
299
332
  # The loose ends in a set of unmatched testcases: the ones that *failed* but carry no
@@ -326,11 +359,23 @@ module Fp
326
359
  marker, precise = marker_for(tc, cases, markers)
327
360
  next unless marker
328
361
 
329
- yield Match.new(testcase: tc, marker: marker, precise: precise, repo_file: repo_file)
362
+ yield Match.new(
363
+ testcase: tc, marker: marker, precise: precise, repo_file: repo_file,
364
+ source_path: File.expand_path(file, base_dir)
365
+ )
330
366
  end
331
367
  end
332
368
  end
333
369
 
370
+ # Stable across body edits and line movement. A rename or move is intentionally a
371
+ # new identity; marker syntax has no durable per-test id from which to infer that the
372
+ # renamed testcase is the old one.
373
+ def testcase_key(repo_file, testcase)
374
+ Digest::SHA256.hexdigest(
375
+ [repo_file, testcase.classname, testcase.name].map(&:to_s).join("\0")
376
+ )
377
+ end
378
+
334
379
  # The repository root containing `dir`, or nil when `dir` isn't inside one. #
335
380
  # Found by walking up for a `.git` entry rather than shelling out to git, so it also
336
381
  # works in a worktree (where `.git` is a file) and costs nothing.
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Fp
6
+ # Produces a receipt for the exact test source CI executed. Isolation is deliberately
7
+ # conservative: when a complete test body cannot be identified confidently, the whole
8
+ # file is hashed and the scope says so instead of pretending a noisy file hash is an
9
+ # isolated test receipt.
10
+ module SourceFingerprint
11
+ Result = Struct.new(:fingerprint, :scope, :start_line, :end_line, keyword_init: true)
12
+
13
+ BRACE_EXTENSIONS = %w[.js .jsx .mjs .cjs .ts .tsx .kt .kts .swift .java .go .cs .php .rs .scala].freeze
14
+
15
+ module_function
16
+
17
+ def build(path, marker)
18
+ contents = File.binread(path)
19
+ lines = contents.lines
20
+ range = isolated_range(lines, marker, File.extname(path).downcase)
21
+
22
+ if range
23
+ first, last = range
24
+ source = lines[first..last].join
25
+ Result.new(
26
+ fingerprint: Digest::SHA256.hexdigest(source),
27
+ scope: 'isolated_test',
28
+ start_line: first + 1,
29
+ end_line: last + 1
30
+ )
31
+ else
32
+ Result.new(
33
+ fingerprint: Digest::SHA256.hexdigest(contents),
34
+ scope: 'whole_file',
35
+ start_line: 1,
36
+ end_line: lines.length
37
+ )
38
+ end
39
+ rescue Errno::ENOENT, Errno::EACCES
40
+ nil
41
+ end
42
+
43
+ def isolated_range(lines, marker, extension)
44
+ return nil unless marker[:title_kind] == :test
45
+
46
+ start_index = declaration_index(lines, marker)
47
+ return nil if start_index.nil? || start_index.negative? || start_index >= lines.length
48
+
49
+ case extension
50
+ when '.rb'
51
+ ruby_range(lines, start_index)
52
+ when '.py'
53
+ python_range(lines, start_index)
54
+ when *BRACE_EXTENSIONS
55
+ brace_range(lines, start_index)
56
+ end
57
+ end
58
+
59
+ def declaration_index(lines, marker)
60
+ marker_index = marker[:line].to_i - 1
61
+ ((marker_index + 1)...[marker_index + 41, lines.length].min).each do |index|
62
+ line = lines[index]
63
+ stripped = line.strip
64
+ next if stripped.empty? || stripped.match?(%r{\A(?:#|//|--|/\*|\*)})
65
+
66
+ return index
67
+ end
68
+ nil
69
+ end
70
+
71
+ def ruby_range(lines, start_index)
72
+ declaration = strip_ruby_literals(lines[start_index])
73
+ return nil if declaration.nil?
74
+ return nil unless declaration.match?(/\bdef\s+test_?\w+/i) ||
75
+ declaration.match?(/\b(?:it|specify|scenario)\b.*\bdo\b/)
76
+
77
+ depth = 0
78
+ (start_index...lines.length).each do |index|
79
+ code = strip_ruby_literals(lines[index])
80
+ return nil if code.nil?
81
+
82
+ depth += code.scan(/\bdo\b/).length
83
+ depth += 1 if code.match?(/^\s*(?:def|class|module|if|unless|case|begin|while|until|for)\b/)
84
+ depth -= code.scan(/\bend\b/).length
85
+ return [start_index, index] if depth.zero? && index >= start_index
86
+ return nil if depth.negative?
87
+ end
88
+ nil
89
+ end
90
+
91
+ def strip_ruby_literals(line)
92
+ return nil if line.match?(/<<[-~]?['"`]?\w+/)
93
+
94
+ line.gsub(/'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*"/, '').sub(/#.*/, '')
95
+ end
96
+
97
+ def python_range(lines, start_index)
98
+ declaration = lines[start_index]
99
+ return nil unless declaration.match?(/^\s*(?:async\s+)?def\s+test_?\w+.*:\s*(?:#.*)?$/i)
100
+
101
+ indent = declaration[/\A\s*/].length
102
+ last_body = nil
103
+ ((start_index + 1)...lines.length).each do |index|
104
+ line = lines[index]
105
+ next if line.strip.empty? || line.lstrip.start_with?('#')
106
+
107
+ current_indent = line[/\A\s*/].length
108
+ break if current_indent <= indent
109
+
110
+ last_body = index
111
+ end
112
+ last_body ? [start_index, last_body] : nil
113
+ end
114
+
115
+ def brace_range(lines, start_index)
116
+ depth = 0
117
+ opened = false
118
+ in_block_comment = false
119
+
120
+ (start_index...lines.length).each do |index|
121
+ sanitized, in_block_comment = strip_brace_literals(lines[index], in_block_comment)
122
+ return nil if sanitized.nil?
123
+
124
+ sanitized.each_char do |char|
125
+ if char == '{'
126
+ depth += 1
127
+ opened = true
128
+ elsif char == '}' && opened
129
+ depth -= 1
130
+ return nil if depth.negative?
131
+ end
132
+ end
133
+
134
+ return [start_index, index] if opened && depth.zero?
135
+ return nil if !opened && index - start_index >= 10
136
+ end
137
+ nil
138
+ end
139
+
140
+ # Remove comments and quoted strings before balancing braces. Template literals are
141
+ # intentionally unsupported because interpolation can contain real code braces; those
142
+ # files use the labelled whole-file fallback rather than risk a false boundary.
143
+ def strip_brace_literals(line, in_block_comment)
144
+ return [nil, in_block_comment] if line.include?('`')
145
+
146
+ output = +''
147
+ quote = nil
148
+ escaped = false
149
+ index = 0
150
+ while index < line.length
151
+ char = line[index]
152
+ pair = line[index, 2]
153
+
154
+ if in_block_comment
155
+ if pair == '*/'
156
+ in_block_comment = false
157
+ index += 2
158
+ else
159
+ index += 1
160
+ end
161
+ next
162
+ end
163
+
164
+ if quote
165
+ if escaped
166
+ escaped = false
167
+ elsif char == '\\'
168
+ escaped = true
169
+ elsif char == quote
170
+ quote = nil
171
+ end
172
+ index += 1
173
+ next
174
+ end
175
+
176
+ break if pair == '//'
177
+ if pair == '/*'
178
+ in_block_comment = true
179
+ index += 2
180
+ next
181
+ end
182
+ if char == '"' || char == "'"
183
+ quote = char
184
+ else
185
+ output << char
186
+ end
187
+ index += 1
188
+ end
189
+
190
+ [output, in_block_comment]
191
+ end
192
+ end
193
+ end
data/lib/fp/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.15'
4
+ VERSION = '0.0.17'
5
5
  end
data/lib/fp.rb CHANGED
@@ -5,6 +5,7 @@ require_relative 'fp/config'
5
5
  require_relative 'fp/project_config'
6
6
  require_relative 'fp/parity'
7
7
  require_relative 'fp/client'
8
+ require_relative 'fp/source_fingerprint'
8
9
  require_relative 'fp/junit'
9
10
  require_relative 'fp/output'
10
11
  require_relative 'fp/commands'
data/skills/fp/SKILL.md CHANGED
@@ -49,6 +49,7 @@ fp list --project stowzilla --status draft # Proposed work; avoid duplicates
49
49
  fp list --project stowzilla --gaps # Requirements with a required surface lacking evidence
50
50
  fp show <slug> --project stowzilla # Requirement details and evidence
51
51
  fp history <slug> --project stowzilla # Origins and pending changes
52
+ fp changes --source-url <work-item-url> # Activity prompted by one card, issue, or thread
52
53
  fp matrix --project stowzilla # ASCII parity matrix
53
54
  fp matrix --project stowzilla --csv # Export as CSV
54
55
  fp check --project stowzilla # Enforce parity (exits 1 on violations)
@@ -85,6 +86,27 @@ requirement around it. Identify which promise matches product intent, then use
85
86
  `fp propose-change` (or `--removal`) with the source and reason to put that decision before a
86
87
  human. If the intent is unclear, stop and ask rather than making the catalog say both things.
87
88
 
89
+ **Treat existing tests as protected product evidence.** Before editing an existing test,
90
+ determine what expectation it already records and whether CI has passing evidence for it.
91
+
92
+ - If the test backs passing evidence, do not rewrite it to make the current implementation or a
93
+ new requirement fit. Explicitly flag the intended test edit in the work item and pull request.
94
+ If its expected behaviour would change, propose the requirement change and wait for explicit
95
+ human approval before editing the test; a newly green run must not hide that the proof changed.
96
+ - When FeatureParity reports a changed bound test, run `fp changed-tests` (or add `--json`) and
97
+ compare its accepted and candidate revisions with the requirement. Record a plain-language
98
+ assessment for the exact candidate fingerprint with `fp changed-tests assess`: `still_proves`
99
+ resolves only that fingerprint; `no_longer_proves` and `uncertain` remain open for a human
100
+ product decision. A later fingerprint always needs a new assessment.
101
+ - A new requirement does not retroactively own or supersede a pre-existing failing test, whether
102
+ it is draft or active. Do not change that test, or adopt it as the new requirement's proof,
103
+ merely to make new work pass. Surface the conflict and wait for explicit human approval to
104
+ change the existing expectation.
105
+
106
+ A changed-test finding is an audit signal alongside the independent CI outcome, not a failed
107
+ test. It is also separate from `suspect`, which means requirement wording changed and its
108
+ evidence needs re-verification. Neither permits rewriting a test before product intent is decided.
109
+
88
110
  ### 2. Add the fp:<slug> marker to tests
89
111
 
90
112
  Place a comment immediately before the test to bind it to a requirement:
@@ -117,6 +139,16 @@ fp report <slug> \
117
139
 
118
140
  **Agents report `present` (default) or `stub` only. Never `passing` or `failing`.**
119
141
 
142
+ **Don't re-report a cell CI already covers.** CI evidence (`passing`, `pr_passing`,
143
+ `failing`) is authoritative. If CI has already run the test for a surface, a `present`
144
+ report adds nothing — it won't upgrade the cell, and the server will keep the CI verdict
145
+ rather than downgrade it to a bare marker. Check first with `fp show <slug> --project X`
146
+ (or `fp matrix`): if the surface already shows a CI status, skip the report. Report
147
+ `present`/`stub` for surfaces CI hasn't reached yet — e.g. a brand-new test before its
148
+ first CI run, or local work where CI isn't wired up. When you do report over a CI-covered
149
+ cell anyway, `fp report` now tells you it was superseded (the status stays CI's, your
150
+ report lands as metadata only) so you can see the no-op instead of assuming it took.
151
+
120
152
  ### 4. Report evidence from JUnit XML (batch upload without CI)
121
153
 
122
154
  When CI isn't available, run the suite locally and upload the report:
@@ -332,6 +364,7 @@ fp init --project <slug> --surface <surface>
332
364
  | Propose a change | `fp propose-change <slug> --project X --why "..." --reason "..."` |
333
365
  | Propose a deletion | `fp propose-change <slug> --project X --removal --reason "..."` |
334
366
  | View change history | `fp history <slug> --project X` |
367
+ | View work-item activity | `fp changes --project X --source-url URL` |
335
368
  | Enforce parity | `fp check --project X` |
336
369
  | Gate on this run | `fp check --project X --surface Y --junit file.xml` |
337
370
  | View matrix | `fp matrix --project X` |
@@ -146,6 +146,12 @@ an existing promise is a contradiction signal: decide which promise reflects pro
146
146
  then record that decision with `fp propose-change` or `--removal`. Do not weaken the test or add
147
147
  a second requirement around the conflict; ask a human when product intent is unclear.
148
148
 
149
+ Existing tests are protected product evidence. Flag any intended edit to a test that backs
150
+ passing evidence; if its expected behaviour would change, wait for explicit human approval of
151
+ the requirement change before editing it. A new requirement does not supersede a pre-existing
152
+ failing test merely because the requirement was drafted or activated. Surface that conflict and
153
+ do not change or adopt the old test as proof without explicit human approval.
154
+
149
155
  ```bash
150
156
  fp propose --project stowzilla \
151
157
  --slug current_shipment_status \
@@ -270,6 +276,85 @@ the review decision where one was made. An open proposal is called out at the en
270
276
 
271
277
  `--project` is optional when the repo has a `.featureparity.yml`.
272
278
 
279
+ ### fp changes
280
+
281
+ Show requirement activity prompted by a work item, rather than opening every requirement's
282
+ history separately.
283
+
284
+ ```bash
285
+ fp changes --source-url https://app.fizzy.do/6098707/cards/1434
286
+ fp changes --project stowzilla --source-url https://github.com/acme/app/issues/42
287
+ fp changes --github-pr --github-summary
288
+ ```
289
+
290
+ `--source-url` may be repeated. The API matches the URL exactly against the source recorded
291
+ when a requirement was created, directly edited, proposed for modification, or proposed for
292
+ removal. Results include the requirement, activity kind, review state, actor, and a link to the
293
+ requirement.
294
+
295
+ In a GitHub pull-request workflow, `--github-pr` reads the live PR through the GitHub API and
296
+ writes or replaces one marker-managed requirement table in its description. It requires
297
+ `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, `GITHUB_EVENT_PATH`, and `pull-requests: write`; inability
298
+ to read or update the PR is an error, so a missing table cannot silently pass. Add
299
+ `--github-summary` to keep the same table in `GITHUB_STEP_SUMMARY`. A PR with no linked work
300
+ item says FeatureParity cannot determine the activity; a linked work item with no events
301
+ explicitly says no requirement changes were recorded. Generated `fp init` workflows configure
302
+ both outputs before the parity gate.
303
+
304
+ ### fp changed-tests
305
+
306
+ List every unresolved audit finding for a bound test whose source fingerprint differs from
307
+ the accepted revision. The queue is independent of the testcase's passing/failing/stub outcome
308
+ and from `suspect` evidence caused by changed requirement wording.
309
+
310
+ ```bash
311
+ fp changed-tests # project from .featureparity.yml
312
+ fp changed-tests --project stowzilla --surface api
313
+ fp changed-tests --json # complete machine-readable queue
314
+ ```
315
+
316
+ Each finding includes the current requirement and acceptance rule, surface and optional test
317
+ type, testcase and CI outcome, isolated-test or labelled whole-file fingerprint scope, and the
318
+ accepted and candidate fingerprints, commits, source locations, and links.
319
+
320
+ After comparing those exact revisions with the requirement, an authorized agent records a
321
+ plain-language assessment:
322
+
323
+ ```bash
324
+ fp changed-tests assess <finding-id> \
325
+ --candidate-fingerprint <sha256> \
326
+ --classification still_proves \
327
+ --explanation "The refactor changes setup only; the same observable result is asserted."
328
+ ```
329
+
330
+ Classifications are `still_proves`, `no_longer_proves`, and `uncertain`. `still_proves`
331
+ resolves only the supplied candidate fingerprint; if it came from a PR, that exact fingerprint
332
+ becomes the accepted baseline only after a default-branch CI report observes it. The other two
333
+ classifications remain open for a human product decision. A stale candidate is rejected, and a
334
+ later fingerprint always requires a new assessment. Assessing never changes the requirement
335
+ wording or CI outcome.
336
+
337
+ #### Upgrading an existing project
338
+
339
+ Changed-test auditing requires fp 0.0.17 or newer and the corresponding backend deployment.
340
+ Hosted-project users do not migrate project data or configuration.
341
+
342
+ ```bash
343
+ gem update featureparity
344
+ fp version # 0.0.17 or newer
345
+ ```
346
+
347
+ Fresh CI jobs that run `gem install featureparity` automatically pick up the release; update
348
+ any Bundler or install-command pin explicitly. Keep the existing `fp ci-report` call and run it
349
+ once on the default branch: the upgraded CLI sends source receipts automatically, and that run
350
+ lazily establishes accepted baselines without a backfill. Report trusted pull requests with
351
+ `--pr <url>` to open findings before merge while preserving the default-branch baseline.
352
+
353
+ The workflow recipe generated by fp 0.0.17 shows both paths. Adapt an existing customized
354
+ workflow manually. `fp init --force` overwrites `.featureparity.yml` and the generated workflow,
355
+ so reserve it for unmodified generated files, preserve the original project/surface options,
356
+ and review the resulting diff.
357
+
273
358
  ### fp matrix
274
359
 
275
360
  Display the parity matrix. Cells show the state of the evidence filed for each
@@ -373,7 +458,7 @@ fp init --project stowzilla --dir path/to/repo
373
458
  | File | Purpose |
374
459
  |------|---------|
375
460
  | `.featureparity.yml` | declares the project slug, so `--project` becomes optional |
376
- | `.github/workflows/feature-parity.yml` | the CI gate |
461
+ | `.github/workflows/feature-parity.yml` | the CI gate, PR requirement-change summary, and adaptable PR/default-branch reporting recipe |
377
462
  | `AGENTS.md` (appended) | instructions telling agents to mark and report their tests |
378
463
 
379
464
  Existing files are skipped unless `--force`. The `AGENTS.md` section is appended between