featureparity 0.0.27 → 0.0.29

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fp/cli.rb +14 -2
  3. data/lib/fp/client.rb +79 -2
  4. data/lib/fp/commands/analyze.rb +17 -0
  5. data/lib/fp/commands/base.rb +16 -0
  6. data/lib/fp/commands/check.rb +38 -4
  7. data/lib/fp/commands/ci_report.rb +2 -2
  8. data/lib/fp/commands/comment.rb +1 -1
  9. data/lib/fp/commands/comments.rb +146 -0
  10. data/lib/fp/commands/coverage.rb +149 -0
  11. data/lib/fp/commands/doctor.rb +228 -0
  12. data/lib/fp/commands/help.rb +41 -1
  13. data/lib/fp/commands/history.rb +16 -1
  14. data/lib/fp/commands/init.rb +181 -117
  15. data/lib/fp/commands/list.rb +58 -4
  16. data/lib/fp/commands/mark.rb +167 -0
  17. data/lib/fp/commands/projects.rb +6 -1
  18. data/lib/fp/commands/propose.rb +435 -53
  19. data/lib/fp/commands/propose_change.rb +13 -7
  20. data/lib/fp/commands/question.rb +137 -0
  21. data/lib/fp/commands/report.rb +40 -4
  22. data/lib/fp/commands/run.rb +147 -0
  23. data/lib/fp/commands/show.rb +41 -3
  24. data/lib/fp/commands/spot_check.rb +458 -0
  25. data/lib/fp/commands/tutorial.rb +95 -0
  26. data/lib/fp/commands/whoami.rb +2 -0
  27. data/lib/fp/commands.rb +8 -0
  28. data/lib/fp/config.rb +12 -0
  29. data/lib/fp/junit.rb +74 -11
  30. data/lib/fp/repo_detector.rb +488 -0
  31. data/lib/fp/requirement_lint.rb +105 -0
  32. data/lib/fp/rspec_json.rb +54 -0
  33. data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
  34. data/lib/fp/tutorial/hexhaven/README.md +29 -0
  35. data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
  36. data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
  37. data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
  38. data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
  39. data/lib/fp/version.rb +1 -1
  40. data/lib/fp.rb +1 -0
  41. data/skills/fp/SKILL.md +244 -105
  42. data/skills/fp/references/cli.md +304 -3
  43. metadata +19 -2
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fp
4
+ # Local wording checks for a requirement before it is written (Fizzy #1786).
5
+ #
6
+ # `fp analyze` reviews a requirement already in the catalog; an agent writing a batch
7
+ # of drafts wants the cheap mechanical checks *before* anything is created, so a human
8
+ # can review the batch with `fp propose --dry-run` and nothing lands half-finished.
9
+ # These are the rules from the requirement-writing guidance that a string can be
10
+ # tested against: a promise for a title, no filler, a stated outcome, one rule, an
11
+ # example, and no numbers nobody decided. Everything here is a warning — the human
12
+ # product owner decides scope, and a heuristic doesn't get to veto a draft.
13
+ module RequirementLint
14
+ # Titles that name the work rather than the promise.
15
+ WORK_VERBS = %w[implement add build create refactor fix support enable integrate migrate].freeze
16
+
17
+ # Commentary about the requirement instead of the requirement.
18
+ COMMENTARY_OPENINGS = [
19
+ 'this feature', 'this requirement', 'the goal is', 'users should be able to',
20
+ 'the system should', 'we need to', 'we should', 'as a user'
21
+ ].freeze
22
+
23
+ # Phrases that sell instead of saying anything a reviewer can check.
24
+ FILLER = [
25
+ 'seamless', 'robust', 'user-friendly', 'intuitive', 'best-in-class', 'world-class',
26
+ 'leverage', 'streamline', 'cutting-edge', 'state-of-the-art', 'ensure a smooth',
27
+ 'enhance the user experience', 'improve the user experience', 'delightful'
28
+ ].freeze
29
+
30
+ Finding = Struct.new(:field, :message, keyword_init: true)
31
+
32
+ module_function
33
+
34
+ # spec: a hash with :slug, :title, :why, :acceptance, :source_type (string keys or
35
+ # symbol keys). Returns [Finding, ...] — empty when nothing stood out.
36
+ def check(spec)
37
+ get = ->(k) { (spec[k] || spec[k.to_s]).to_s.strip }
38
+ title = get.call(:title)
39
+ why = get.call(:why)
40
+ acceptance = get.call(:acceptance)
41
+ findings = []
42
+
43
+ first_word = title.split(/\s+/).first.to_s.downcase
44
+ if WORK_VERBS.include?(first_word)
45
+ findings << Finding.new(field: 'title',
46
+ message: "starts with '#{first_word}' — name the promise a user gets, not the work " \
47
+ '(e.g. "Dispatchers see current shipment status", not "Implement status polling")')
48
+ end
49
+
50
+ { 'title' => title, 'why' => why, 'acceptance' => acceptance }.each do |field, text|
51
+ opening = COMMENTARY_OPENINGS.find { |o| text.downcase.start_with?(o) }
52
+ if opening
53
+ findings << Finding.new(field: field,
54
+ message: "opens with \"#{opening}\" — state the promise itself, with the real actor as the subject")
55
+ end
56
+ filler = FILLER.select { |f| text.downcase.include?(f) }
57
+ unless filler.empty?
58
+ findings << Finding.new(field: field,
59
+ message: "uses filler (#{filler.join(', ')}) — say what a reviewer could observe instead")
60
+ end
61
+ end
62
+
63
+ findings << Finding.new(field: 'why', message: 'is missing — who needs this, and what goes wrong without it?') if why.empty?
64
+
65
+ if acceptance.empty?
66
+ findings << Finding.new(field: 'acceptance', message: 'is missing — a reviewer needs a rule they can check')
67
+ else
68
+ rule, example = split_example(acceptance)
69
+ if example.nil?
70
+ findings << Finding.new(field: 'acceptance',
71
+ message: "has no 'Example: ...' — add one concrete case that would pass the rule")
72
+ end
73
+ sentences = rule.split(/(?<=[.!?])\s+(?=[A-Z])/).reject { |s| s.strip.empty? }
74
+ if sentences.length > 2
75
+ findings << Finding.new(field: 'acceptance',
76
+ message: "states #{sentences.length} sentences of rule — if they could pass or fail " \
77
+ 'separately, split them into their own requirements')
78
+ end
79
+ if rule.match?(/\d/)
80
+ findings << Finding.new(field: 'acceptance',
81
+ message: 'contains a number — keep it only if the source decided it; never invent a limit, price or timeout')
82
+ end
83
+ end
84
+
85
+ if title.downcase == why.downcase && !why.empty?
86
+ findings << Finding.new(field: 'why', message: 'repeats the title — say why the outcome matters')
87
+ end
88
+
89
+ if get.call(:source_type).empty?
90
+ findings << Finding.new(field: 'source',
91
+ message: 'none given — pass --source-type/--source-url (repo_scan with --source-ref when you read it from the code)')
92
+ end
93
+
94
+ findings
95
+ end
96
+
97
+ # [rule, example_or_nil] — the example is whatever follows "Example:".
98
+ def split_example(acceptance)
99
+ idx = acceptance =~ /\bexample:/i
100
+ return [acceptance, nil] if idx.nil?
101
+
102
+ [acceptance[0...idx].strip, acceptance[idx..].sub(/\Aexample:\s*/i, '').strip]
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Fp
6
+ module JUnit
7
+ # RSpec's built-in JSON report (`rspec --format json --out rspec.json`), read into the
8
+ # same TestCase structs a JUnit report produces (Fizzy #1786). Getting JUnit out of
9
+ # RSpec takes the `rspec_junit_formatter` gem, which most Gemfiles don't carry, so a
10
+ # first `fp report` meant editing the project before it could report anything. The
11
+ # JSON formatter ships with rspec-core and carries everything binding needs: the file,
12
+ # the example's line, its full description and its status.
13
+ module RspecJson
14
+ module_function
15
+
16
+ # Content sniffing rather than a flag or a file extension: every `--junit` option
17
+ # accepts it unchanged, and an XML report can never start with `{`.
18
+ def json?(content)
19
+ content.lstrip.start_with?('{')
20
+ end
21
+
22
+ def parse(content, path = 'report')
23
+ data = JSON.parse(content)
24
+ examples = data.is_a?(Hash) ? data['examples'] : nil
25
+ unless examples.is_a?(Array)
26
+ raise ArgumentError,
27
+ "#{path} is JSON but not RSpec's --format json output (no \"examples\" list). " \
28
+ 'Pass a JUnit XML report or `rspec --format json --out <file>`.'
29
+ end
30
+
31
+ examples.map { |example| testcase(example) }
32
+ rescue JSON::ParserError => e
33
+ raise ArgumentError, "Could not parse JSON report (#{path}): #{e.message}"
34
+ end
35
+
36
+ # passed → present; pending (RSpec's `skip`/`pending`/`xit`) → skipped, which
37
+ # agents file as a stub, as with a JUnit <skipped/>; failed → failed, which only
38
+ # CI acts on.
39
+ def testcase(example)
40
+ file = example['file_path'].to_s
41
+ status = example['status'].to_s
42
+ TestCase.new(
43
+ name: (example['full_description'] || example['description']).to_s,
44
+ classname: file,
45
+ file: file.empty? ? nil : file,
46
+ line: example['line_number']&.to_i,
47
+ status: status == 'pending' ? :skipped : :present,
48
+ failed: status == 'failed',
49
+ attachments: []
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,5 @@
1
+ # Hexhaven: agent notes
2
+
3
+ Practice repo for the FeatureParity tutorial: a made-up board game's game server.
4
+ Tests: `ruby -Ilib test/rules_test.rb` (prefix `JUNIT_OUT=junit.xml` for a report).
5
+ Everything here is the `server` surface, so pin markers with `@server`.
@@ -0,0 +1,29 @@
1
+ # Hexhaven: your FeatureParity practice table
2
+
3
+ Hexhaven's game server: the referee that checks every move. Rules in `lib/`, tests in
4
+ `test/`. Plain Ruby; nothing to install.
5
+
6
+ 1. Run the tests:
7
+
8
+ ```sh
9
+ ruby -Ilib test/rules_test.rb
10
+ ```
11
+
12
+ 2. Put this above the last test in `test/rules_test.rb`, or ask your agent to
13
+ (`AGENTS.md` tells it how):
14
+
15
+ ```ruby
16
+ # fp:tallest_tower_scores_two_points@server
17
+ ```
18
+
19
+ 3. Report it:
20
+
21
+ ```sh
22
+ JUNIT_OUT=junit.xml ruby -Ilib test/rules_test.rb
23
+ fp report --junit junit.xml --repo hexhaven/server
24
+ ```
25
+
26
+ The tallest-tower cell on Server turns from **missing** to **present**. Back to the
27
+ tutorial.
28
+
29
+ Made a mess? `fp tutorial --restart`
@@ -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.29'
5
5
  end
data/lib/fp.rb CHANGED
@@ -7,6 +7,7 @@ require_relative 'fp/parity'
7
7
  require_relative 'fp/client'
8
8
  require_relative 'fp/source_fingerprint'
9
9
  require_relative 'fp/junit'
10
+ require_relative 'fp/repo_detector'
10
11
  require_relative 'fp/output'
11
12
  require_relative 'fp/commands'
12
13
  require_relative 'fp/cli'