featureparity 0.0.27 → 0.0.28

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.
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Hexhaven — a made-up board game, and the rules its game server referees.
4
+ #
5
+ # Every rule here is a requirement in your FeatureParity tutorial project. The server checks
6
+ # every move; Web, iOS and Android each show the same rules on their own, and FeatureParity
7
+ # proves all four agree.
8
+ module Hexhaven
9
+ DIE = (1..6).freeze
10
+ HARBOR_RATE = 3
11
+ TALLEST_TOWER_MINIMUM = 5
12
+ TALLEST_TOWER_POINTS = 2
13
+
14
+ # A turn starts with two six-sided dice.
15
+ def self.roll(rng = Random.new)
16
+ [rng.rand(DIE), rng.rand(DIE)]
17
+ end
18
+
19
+ # At a harbor, three of one good buy one of any other.
20
+ def self.harbor_trade(hand, give:, get:)
21
+ raise ArgumentError, "a harbor trade needs #{HARBOR_RATE} #{give}" if hand.fetch(give, 0) < HARBOR_RATE
22
+
23
+ hand.merge(give => hand[give] - HARBOR_RATE, get => hand.fetch(get, 0) + 1)
24
+ end
25
+
26
+ # Who holds the tallest-tower bonus, given each player's tallest tower in floors. The
27
+ # first tower of five floors or more takes it; only a strictly taller one steals it.
28
+ def self.tallest_tower(towers, holder: nil)
29
+ contenders = towers.select { |_, floors| floors >= TALLEST_TOWER_MINIMUM }
30
+ return nil if contenders.empty?
31
+
32
+ best = contenders.values.max
33
+ holder = nil unless holder && towers.fetch(holder, 0) == best
34
+ holder || contenders.key(best)
35
+ end
36
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A dependency-free JUnit XML reporter for Minitest, written only when JUNIT_OUT is set.
4
+ #
5
+ # `fp report --junit` reads the report, opens each test's file, and finds the fp:<slug>
6
+ # marker above it. That is why every testcase carries the file and line it was declared
7
+ # on.
8
+
9
+ require 'minitest'
10
+ require 'fileutils'
11
+
12
+ module MinitestJUnitReporter
13
+ class Reporter < Minitest::AbstractReporter
14
+ def initialize(output_path, base_dir)
15
+ super()
16
+ @output_path = output_path
17
+ @base_dir = File.expand_path(base_dir)
18
+ @results = []
19
+ end
20
+
21
+ def record(result)
22
+ @results << result
23
+ end
24
+
25
+ def report
26
+ FileUtils.mkdir_p(File.dirname(@output_path))
27
+ File.write(@output_path, xml)
28
+ end
29
+
30
+ private
31
+
32
+ def xml
33
+ failures = @results.count { |r| r.failures.any? { |f| !f.is_a?(Minitest::Skip) } }
34
+ skips = @results.count { |r| r.failures.any? { |f| f.is_a?(Minitest::Skip) } }
35
+ time = @results.sum(&:time)
36
+
37
+ body = @results.map { |r| testcase_xml(r) }.join
38
+
39
+ <<~XML
40
+ <?xml version="1.0" encoding="UTF-8"?>
41
+ <testsuites>
42
+ <testsuite name="minitest" tests="#{@results.size}" failures="#{failures}" errors="0" skipped="#{skips}" time="#{format('%.6f', time)}">
43
+ #{body} </testsuite>
44
+ </testsuites>
45
+ XML
46
+ end
47
+
48
+ def testcase_xml(result)
49
+ file, line = declaring_location(result)
50
+ attrs = [
51
+ %(name="#{esc(result.name)}"),
52
+ %(classname="#{esc(result.klass)}"),
53
+ %(file="#{esc(relative_to_base(file))}"),
54
+ # `line` is what lets `fp` bind each marker to the specific test below it. Without
55
+ # it fp can only handle files with exactly one marker.
56
+ (%(line="#{line}") if line),
57
+ %(time="#{format('%.6f', result.time)}")
58
+ ].compact.join(' ')
59
+
60
+ skip = result.failures.find { |f| f.is_a?(Minitest::Skip) }
61
+ real_failures = result.failures.reject { |f| f.is_a?(Minitest::Skip) }
62
+
63
+ if skip
64
+ %( <testcase #{attrs}>\n <skipped message="#{esc(skip.message)}"/>\n </testcase>\n)
65
+ elsif real_failures.any?
66
+ inner = real_failures.map do |f|
67
+ tag = f.is_a?(Minitest::UnexpectedError) ? 'error' : 'failure'
68
+ %( <#{tag} message="#{esc(f.message)}"/>\n)
69
+ end.join
70
+ %( <testcase #{attrs}>\n#{inner} </testcase>\n)
71
+ else
72
+ %( <testcase #{attrs}/>\n)
73
+ end
74
+ end
75
+
76
+ # Where the test method is declared, as [path, line]. The path is what `fp` opens to
77
+ # look for markers and the line is what binds a marker to its test, so this matters
78
+ # more than the rest of the report.
79
+ def declaring_location(result)
80
+ # Minitest 5.18+ exposes source_location on the result.
81
+ return result.source_location if result.respond_to?(:source_location)
82
+
83
+ # Older minitest: ask the test class for the method's definition site.
84
+ klass = Object.const_get(result.klass.to_s)
85
+ klass.instance_method(result.name).source_location || [nil, nil]
86
+ rescue StandardError
87
+ [nil, nil]
88
+ end
89
+
90
+ def relative_to_base(path)
91
+ return '' unless path
92
+
93
+ absolute = File.expand_path(path)
94
+ relative = absolute.delete_prefix("#{@base_dir}/")
95
+ relative == absolute ? path : relative
96
+ rescue StandardError
97
+ ''
98
+ end
99
+
100
+ def esc(str)
101
+ str.to_s
102
+ .gsub('&', '&amp;').gsub('<', '&lt;').gsub('>', '&gt;')
103
+ .gsub('"', '&quot;').gsub("'", '&apos;')
104
+ .gsub(/[^[:print:]\t\n]/, '')
105
+ end
106
+ end
107
+
108
+ class << self
109
+ # Register as a Minitest plugin. Minitest calls plugin_<name>_init while building its
110
+ # composite reporter, which is the only point where a reporter can still be added.
111
+ # No-op unless JUNIT_OUT is set, so ordinary test runs are unaffected.
112
+ def install!
113
+ path = ENV['JUNIT_OUT']
114
+ return if path.nil? || path.strip.empty?
115
+ return if @installed
116
+
117
+ @installed = true
118
+ @output_path = File.expand_path(path)
119
+ @base_dir = ENV['JUNIT_BASE_DIR'] || Dir.pwd
120
+
121
+ Minitest.extensions << 'junit_xml' unless Minitest.extensions.include?('junit_xml')
122
+
123
+ Minitest.singleton_class.define_method(:plugin_junit_xml_init) do |_options|
124
+ Minitest.reporter << MinitestJUnitReporter.build_reporter
125
+ end
126
+ end
127
+
128
+ def build_reporter
129
+ Reporter.new(@output_path, @base_dir)
130
+ end
131
+ end
132
+ end
133
+
134
+ MinitestJUnitReporter.install!
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'test_helper'
4
+
5
+ # The referee's rules, each bound to the requirement it proves by the fp: comment directly
6
+ # above it. `@server` pins the surface, since this repo is the game server.
7
+ class RulesTest < Minitest::Test
8
+ # fp:turn_starts_with_two_dice@server
9
+ def test_a_turn_rolls_two_six_sided_dice
10
+ 100.times do
11
+ dice = Hexhaven.roll
12
+ assert_equal 2, dice.size
13
+ dice.each { |die| assert_includes 1..6, die }
14
+ end
15
+ end
16
+
17
+ # fp:harbor_trades_three_for_one@server
18
+ def test_three_wool_become_one_brick_at_a_harbor
19
+ hand = Hexhaven.harbor_trade({ wool: 3 }, give: :wool, get: :brick)
20
+
21
+ assert_equal({ wool: 0, brick: 1 }, hand)
22
+ end
23
+
24
+ # fp:harbor_trades_three_for_one@server
25
+ def test_a_harbor_refuses_fewer_than_three
26
+ assert_raises(ArgumentError) { Hexhaven.harbor_trade({ wool: 2 }, give: :wool, get: :brick) }
27
+ end
28
+
29
+ # 👉 Your move: put this line (uncommented) above the test, or ask your agent to.
30
+ # # fp:tallest_tower_scores_two_points@server
31
+ def test_a_six_floor_tower_takes_the_bonus_from_a_five_floor_tower
32
+ assert_equal :ada, Hexhaven.tallest_tower({ ada: 5, ben: 3 })
33
+ assert_equal :ben, Hexhaven.tallest_tower({ ada: 5, ben: 6 }, holder: :ada)
34
+ assert_nil Hexhaven.tallest_tower({ ada: 4, ben: 4 })
35
+ end
36
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'minitest/autorun'
4
+ # Writes a JUnit report when JUNIT_OUT is set, which is what `fp report --junit` reads.
5
+ require_relative 'junit_reporter'
6
+ require_relative '../lib/hexhaven'
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.27'
4
+ VERSION = '0.0.28'
5
5
  end
@@ -266,6 +266,12 @@ fp propose --project stowzilla \
266
266
  | `--required` | No | Comma-separated surfaces that must implement this |
267
267
  | `--acceptance` | No | Observable behaviour and material boundaries |
268
268
 
269
+ After creating the draft, `fp propose` prints its quality feedback — a verdict, the issues
270
+ found, and suggestions — in the same run (`analysis` in `--json`). Act on it: revise a weak
271
+ draft with `fp propose-change`, or withdraw one that shouldn't be a requirement. When analysis
272
+ is unavailable the draft is still created, and the output says the feedback couldn't be
273
+ produced (`analysis_error`).
274
+
269
275
  **Write for a decision-maker, not an implementer.** The result should sound natural and be
270
276
  ready for management review without someone translating AI boilerplate or reverse-engineering
271
277
  the intended outcome. Treat it as the current record of an ongoing conversation, not a finished
@@ -370,6 +376,27 @@ the review decision where one was made. An open proposal is called out at the en
370
376
 
371
377
  `--project` is optional when the repo has a `.featureparity.yml`.
372
378
 
379
+ ### fp comments
380
+
381
+ Read or join the team's conversation on a requirement, and tag people or agents in it.
382
+
383
+ ```bash
384
+ fp comments print_container_qr --project stowzilla # comments + who can be tagged
385
+ fp comments print_container_qr --body "The label redesign dropped this — retire it?" \
386
+ --tag andy@example.com
387
+ fp comments print_container_qr --body "Can you re-check this against the new label?" --tag claude
388
+ ```
389
+
390
+ A comment never changes the requirement. `--tag` takes a teammate's email or name, or one of
391
+ the team's agents by name or key name; an unknown or ambiguous tag stops the command and
392
+ posts nothing. Tagged people get an email; each tagged agent fires the project's
393
+ `requirement.agent_mentioned` webhook, whose payload names the agent, the requirement and
394
+ the comment, plus the `fp` commands to read and answer it. If a human tagged you, answer
395
+ with `fp comments <slug> --body "..."` — and if the question means the requirement is
396
+ wrong, propose the change with `fp propose-change` rather than settling it in a comment.
397
+
398
+ `fp comment` (singular) is different: it threads a comment onto an open change proposal.
399
+
373
400
  ### fp analyze
374
401
 
375
402
  Triage requirement quality: duplicates, fragments of a broader promise, vague wording,
@@ -459,6 +486,52 @@ classifications remain open for a human product decision. A stale candidate is r
459
486
  later fingerprint always requires a new assessment. Assessing never changes the requirement
460
487
  wording or CI outcome.
461
488
 
489
+ ### fp spot-check
490
+
491
+ A random drug test for evidence. A green receipt proves a marked test ran and passed, not
492
+ that it checks what the requirement promises. `fp spot-check` picks one active requirement
493
+ that has evidence on record, at random, and prints its why and acceptance, its receipts, and
494
+ the source of every `fp:<slug>` test it finds in the checkout, then a brief.
495
+
496
+ ```bash
497
+ fp spot-check # random pick; prints the seed
498
+ fp spot-check --seed 4821 # repeat that pick
499
+ fp spot-check --surface cli --json # narrow to one surface; machine-readable
500
+ fp spot-check print_container_qr print_label # chosen requirements, any status
501
+ fp spot-check --count 5 # five distinct requirements at random
502
+ fp spot-check --all # every active requirement with evidence
503
+ fp spot-check --test test/qr_test.rb # every marked test in the file
504
+ fp spot-check --test test/qr_test.rb:42 # only the test spanning that line
505
+ fp spot-check --path ../android # look for the tests in another checkout
506
+ ```
507
+
508
+ Use one way of choosing per run: slugs, `--count`, `--all`, or `--test` (which may be
509
+ repeated). The seed is printed only for the random picks. In `--json` the result is
510
+ `checks` (one entry per requirement, each with `requirement`, `evidence` and `tests`), plus
511
+ a single `brief` that applies to every check.
512
+
513
+ `--prompt` prints the whole spot check as instructions for an agent, so a person or a
514
+ schedule can hand it over as is (`fp spot-check --count 3 --prompt | claude -p`). It ends
515
+ with what to report for each requirement: `proves`, `does_not_prove` or `uncertain`, the
516
+ clauses no assertion checks, the probe made and the tests that failed, and a clean
517
+ `git status`. It can't be combined with `--json`.
518
+
519
+ `--jev` first sends each requirement's marked tests (up to 10) to Jev and adds its read:
520
+ `agree`, `gaps` or `uncertain`, how likely the tests are to cover the acceptance, the
521
+ likeliest gap (`unasserted_clause`, `asserts_other_behaviour`, `cannot_fail`,
522
+ `tests_a_double`), and a per-test likelihood. Treat it as a hint about where to aim the
523
+ probe. It doesn't replace the probe. Jev is for paid plans: a person on Pro or Lifetime,
524
+ or a project whose owner is (that is what lets an agent use it). Otherwise the spot check
525
+ prints a note and carries on without it. Nothing Jev reads is stored.
526
+
527
+ The brief: list each result and boundary the acceptance promises; name the clause each
528
+ test's assertions actually check; make one small change to the *product code* that breaks
529
+ the promise, run only those tests, and confirm one fails; revert and confirm `git status` is
530
+ clean. A test that stays green under the probe, or checks something the acceptance doesn't
531
+ say, doesn't prove the requirement. Report that to a human with what you tried. Don't
532
+ rewrite the test or the requirement as part of a spot check. The command itself is
533
+ read-only.
534
+
462
535
  #### Upgrading an existing project
463
536
 
464
537
  Changed-test auditing requires fp 0.0.17 or newer and the corresponding backend deployment.
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: featureparity
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.27
4
+ version: 0.0.28
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stowzilla
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-11-02 00:00:00.000000000 Z
11
+ date: 2026-11-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-http
@@ -103,6 +103,7 @@ files:
103
103
  - lib/fp/commands/check.rb
104
104
  - lib/fp/commands/ci_report.rb
105
105
  - lib/fp/commands/comment.rb
106
+ - lib/fp/commands/comments.rb
106
107
  - lib/fp/commands/config_cmd.rb
107
108
  - lib/fp/commands/conversation.rb
108
109
  - lib/fp/commands/export.rb
@@ -121,7 +122,9 @@ files:
121
122
  - lib/fp/commands/show.rb
122
123
  - lib/fp/commands/showroom.rb
123
124
  - lib/fp/commands/showroom_media.rb
125
+ - lib/fp/commands/spot_check.rb
124
126
  - lib/fp/commands/surfaces.rb
127
+ - lib/fp/commands/tutorial.rb
125
128
  - lib/fp/commands/version.rb
126
129
  - lib/fp/commands/whoami.rb
127
130
  - lib/fp/config.rb
@@ -130,6 +133,12 @@ files:
130
133
  - lib/fp/parity.rb
131
134
  - lib/fp/project_config.rb
132
135
  - lib/fp/source_fingerprint.rb
136
+ - lib/fp/tutorial/hexhaven/AGENTS.md
137
+ - lib/fp/tutorial/hexhaven/README.md
138
+ - lib/fp/tutorial/hexhaven/lib/hexhaven.rb
139
+ - lib/fp/tutorial/hexhaven/test/junit_reporter.rb
140
+ - lib/fp/tutorial/hexhaven/test/rules_test.rb
141
+ - lib/fp/tutorial/hexhaven/test/test_helper.rb
133
142
  - lib/fp/version.rb
134
143
  - skills/fp/SKILL.md
135
144
  - skills/fp/references/cli.md