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.
- checksums.yaml +4 -4
- data/lib/fp/cli.rb +14 -2
- data/lib/fp/client.rb +79 -2
- data/lib/fp/commands/analyze.rb +17 -0
- data/lib/fp/commands/base.rb +16 -0
- data/lib/fp/commands/check.rb +38 -4
- data/lib/fp/commands/ci_report.rb +2 -2
- data/lib/fp/commands/comment.rb +1 -1
- data/lib/fp/commands/comments.rb +146 -0
- data/lib/fp/commands/coverage.rb +149 -0
- data/lib/fp/commands/doctor.rb +228 -0
- data/lib/fp/commands/help.rb +41 -1
- data/lib/fp/commands/history.rb +16 -1
- data/lib/fp/commands/init.rb +181 -117
- data/lib/fp/commands/list.rb +58 -4
- data/lib/fp/commands/mark.rb +167 -0
- data/lib/fp/commands/projects.rb +6 -1
- data/lib/fp/commands/propose.rb +435 -53
- data/lib/fp/commands/propose_change.rb +13 -7
- data/lib/fp/commands/question.rb +137 -0
- data/lib/fp/commands/report.rb +40 -4
- data/lib/fp/commands/run.rb +147 -0
- data/lib/fp/commands/show.rb +41 -3
- data/lib/fp/commands/spot_check.rb +458 -0
- data/lib/fp/commands/tutorial.rb +95 -0
- data/lib/fp/commands/whoami.rb +2 -0
- data/lib/fp/commands.rb +8 -0
- data/lib/fp/config.rb +12 -0
- data/lib/fp/junit.rb +74 -11
- data/lib/fp/repo_detector.rb +488 -0
- data/lib/fp/requirement_lint.rb +105 -0
- data/lib/fp/rspec_json.rb +54 -0
- data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
- data/lib/fp/tutorial/hexhaven/README.md +29 -0
- data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
- data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +244 -105
- data/skills/fp/references/cli.md +304 -3
- metadata +19 -2
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
module Commands
|
|
5
|
+
# fp coverage [--project X] [--surface api] [--all] [--json]
|
|
6
|
+
#
|
|
7
|
+
# Draft coverage: which draft requirements have marked tests or evidence on which
|
|
8
|
+
# surfaces, and which have none.
|
|
9
|
+
#
|
|
10
|
+
# The gate measures active requirements only (plus ones an open proposal demoted), so
|
|
11
|
+
# on a freshly onboarded project — where every requirement is still a draft — `fp
|
|
12
|
+
# check` says "Nothing to enforce yet" and `fp list --gaps` says "No gaps found". The
|
|
13
|
+
# agent that just proposed the catalog and marked the tests had no way to confirm its
|
|
14
|
+
# work, and nothing to hand the human but "trust me" (Fizzy #1786). This reads the
|
|
15
|
+
# parity report's `drafts` section, where the server resolves each draft's cells from
|
|
16
|
+
# evidence exactly as it would once the draft is active.
|
|
17
|
+
#
|
|
18
|
+
# It ends with a plain list of drafts that have no evidence on a required surface —
|
|
19
|
+
# the "requirements with no test" section an onboarding agent hands the human.
|
|
20
|
+
#
|
|
21
|
+
# Informational only. Drafts are reported, never gated; this command always exits 0
|
|
22
|
+
# on a successful read.
|
|
23
|
+
class Coverage < Base
|
|
24
|
+
help_summary 'Show which draft requirements have evidence on which surfaces (never gated)'
|
|
25
|
+
help_usage 'fp coverage [--project <slug>] [--surface X] [--all]'
|
|
26
|
+
help_flag :project, 'Project slug (falls back to .featureparity.yml)'
|
|
27
|
+
help_flag :surface, 'Limit to one surface'
|
|
28
|
+
help_flag :all, 'Also list the measured requirements (active, or under review) the gate checks'
|
|
29
|
+
help_example 'fp coverage', 'Drafts × surfaces, then the drafts with no evidence'
|
|
30
|
+
help_example 'fp coverage --all --json'
|
|
31
|
+
help_note "Drafts are not enforced: a human activates a draft before fp check gates on it.\n" \
|
|
32
|
+
'A cell reads the same here as it will in the matrix once the draft is active.'
|
|
33
|
+
|
|
34
|
+
KNOWN_FLAGS = {
|
|
35
|
+
project: :string,
|
|
36
|
+
surface: :string,
|
|
37
|
+
all: :boolean
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
def run(args)
|
|
41
|
+
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
42
|
+
project_config = ProjectConfig.discover
|
|
43
|
+
project_slug = require_project!(opts, project_config)
|
|
44
|
+
project_id = resolve_project_id(project_slug)
|
|
45
|
+
surface = opts[:surface] || project_config.surface
|
|
46
|
+
|
|
47
|
+
result = client.get_parity_with_drafts(project_id, surface: surface)
|
|
48
|
+
unless result[:ok]
|
|
49
|
+
output.error(result[:error], status: result[:status])
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
report = result[:data]['parity'] || {}
|
|
54
|
+
unless report.key?('drafts')
|
|
55
|
+
output.error('This FeatureParity API does not report draft coverage yet (it predates ' \
|
|
56
|
+
'`include_drafts`). Nothing was checked.')
|
|
57
|
+
exit 1
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
render(report, project_slug, surface, all: opts[:all])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Drafts with no evidence on at least one required surface, as
|
|
64
|
+
# [{ 'slug', 'title', 'surfaces' => [...] }]. "No evidence" is the `missing` state:
|
|
65
|
+
# nothing — not even a stub or a red test — is filed for that surface.
|
|
66
|
+
def self.untested(drafts)
|
|
67
|
+
Array(drafts).filter_map do |row|
|
|
68
|
+
bare = Array(row['cells']).select { |c| c['state'] == 'missing' }.map { |c| c['surface'] }
|
|
69
|
+
next if bare.empty?
|
|
70
|
+
|
|
71
|
+
{ 'slug' => row['slug'], 'title' => row['title'], 'surfaces' => bare }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The drafts × surfaces table plus the "no evidence" list, for text output. Shared
|
|
76
|
+
# with `fp check --include-drafts`, so the two can't describe drafts differently.
|
|
77
|
+
def self.print_drafts(output, rows, surfaces, show_status: false)
|
|
78
|
+
if rows.empty?
|
|
79
|
+
puts 'No draft requirements.'
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
headers = %w[SLUG]
|
|
84
|
+
headers << 'STATUS' if show_status
|
|
85
|
+
headers += surfaces
|
|
86
|
+
table = rows.map do |row|
|
|
87
|
+
by_surface = Array(row['cells']).to_h { |c| [c['surface'], c['state']] }
|
|
88
|
+
cells = surfaces.map { |key| by_surface.key?(key) ? Parity.glyph(by_surface[key]) : Parity::NOT_REQUIRED }
|
|
89
|
+
line = [row['slug']]
|
|
90
|
+
line << row['status'] if show_status
|
|
91
|
+
line + cells
|
|
92
|
+
end
|
|
93
|
+
output.table(headers, table)
|
|
94
|
+
puts Parity::LEGEND
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.print_untested(untested)
|
|
98
|
+
puts
|
|
99
|
+
if untested.empty?
|
|
100
|
+
puts 'Every draft has evidence on each of its required surfaces.'
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
puts "#{untested.size} draft(s) with no evidence on a required surface:"
|
|
105
|
+
untested.each { |u| puts " #{u['slug']} — no evidence on: #{u['surfaces'].join(', ')}" }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def render(report, project_slug, surface, all:)
|
|
111
|
+
drafts = report['drafts'] || []
|
|
112
|
+
surfaces = report['surfaces'] || []
|
|
113
|
+
untested = self.class.untested(drafts)
|
|
114
|
+
|
|
115
|
+
payload = {
|
|
116
|
+
'project' => project_slug,
|
|
117
|
+
'surface' => surface,
|
|
118
|
+
'surfaces' => surfaces,
|
|
119
|
+
'drafts' => drafts,
|
|
120
|
+
'untested' => untested
|
|
121
|
+
}
|
|
122
|
+
payload['measured'] = report['requirements'] || [] if all
|
|
123
|
+
|
|
124
|
+
output.success(payload) do
|
|
125
|
+
scope = surface ? " (surface: #{surface})" : ''
|
|
126
|
+
puts "Draft coverage: #{project_slug}#{scope}"
|
|
127
|
+
puts
|
|
128
|
+
|
|
129
|
+
if all
|
|
130
|
+
measured = report['requirements'] || []
|
|
131
|
+
puts "Measured by the gate (#{measured.size}):"
|
|
132
|
+
if measured.empty?
|
|
133
|
+
puts 'None — no requirement is active yet.'
|
|
134
|
+
else
|
|
135
|
+
self.class.print_drafts(output, measured, surfaces, show_status: true)
|
|
136
|
+
end
|
|
137
|
+
puts
|
|
138
|
+
puts "Drafts (#{drafts.size}):"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
self.class.print_drafts(output, drafts, surfaces)
|
|
142
|
+
self.class.print_untested(untested) unless drafts.empty?
|
|
143
|
+
puts
|
|
144
|
+
puts 'Drafts are reported, never gated. A human activates a draft before fp check enforces it.'
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rbconfig'
|
|
4
|
+
|
|
5
|
+
module Fp
|
|
6
|
+
module Commands
|
|
7
|
+
# fp doctor [--dir PATH] [--json]
|
|
8
|
+
#
|
|
9
|
+
# Says whether fp will work here, and what this repo needs before its tests can report
|
|
10
|
+
# (Fizzy #1786). Purely local: no API calls, no key needed.
|
|
11
|
+
#
|
|
12
|
+
# Ruby which Ruby is running fp, where the gem lives, and whether this directory
|
|
13
|
+
# pins a different Ruby (.ruby-version, .tool-versions, mise.toml). A
|
|
14
|
+
# version-manager shim resolves `fp` against the *pinned* Ruby, so a gem
|
|
15
|
+
# installed under another one fails inside the repo with
|
|
16
|
+
# `mise ERROR No version is set for shim: fp` and works from /tmp.
|
|
17
|
+
# Config the API environment (warning off production), whether a key resolved,
|
|
18
|
+
# and which .featureparity.yml applies.
|
|
19
|
+
# Repo RepoDetector's summary: test runners and the command that makes each emit
|
|
20
|
+
# a report, suggested surfaces, database and devcontainer.
|
|
21
|
+
#
|
|
22
|
+
# The shim error itself happens before fp starts, so doctor can only catch it when fp
|
|
23
|
+
# is reached some other way (another Ruby's bin dir, from /tmp). The skill and the
|
|
24
|
+
# generated AGENTS.md section describe that case for the agent that hits it.
|
|
25
|
+
class Doctor < Base
|
|
26
|
+
help_summary 'Check that fp works here: Ruby and version-manager pins, config, and what the repo needs'
|
|
27
|
+
help_usage 'fp doctor [--dir PATH]'
|
|
28
|
+
help_flag :dir, 'Directory to check (defaults to the working directory)'
|
|
29
|
+
help_example 'fp doctor'
|
|
30
|
+
help_example 'fp doctor --json', 'Machine-readable, for an agent to act on'
|
|
31
|
+
help_note "Purely local; makes no API calls, so it works without a key.\n" \
|
|
32
|
+
"If `fp` itself fails with `mise ERROR No version is set for shim: fp`, the shim stopped it\n" \
|
|
33
|
+
'before fp could run: install the gem under the pinned Ruby (`gem install featureparity` in the repo).'
|
|
34
|
+
|
|
35
|
+
KNOWN_FLAGS = { dir: :string }.freeze
|
|
36
|
+
|
|
37
|
+
# Nearest file wins, and within one directory mise's own precedence applies.
|
|
38
|
+
PIN_FILES = %w[mise.toml .mise.toml .tool-versions .ruby-version].freeze
|
|
39
|
+
|
|
40
|
+
def run(args)
|
|
41
|
+
opts, = parse_flags(args, KNOWN_FLAGS)
|
|
42
|
+
dir = File.expand_path(opts[:dir] || Dir.pwd)
|
|
43
|
+
unless File.directory?(dir)
|
|
44
|
+
output.error("Not a directory: #{dir}")
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
report = {
|
|
49
|
+
ruby: ruby_report(dir),
|
|
50
|
+
config: config_report(dir),
|
|
51
|
+
repo: RepoDetector.new(repo_root(dir)).detect
|
|
52
|
+
}
|
|
53
|
+
report[:agents_section] = agents_section_report(repo_root(dir))
|
|
54
|
+
|
|
55
|
+
output.success(report) { print_report(report) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The nearest Ruby pin at or above dir: { version:, file:, manager: } or nil.
|
|
59
|
+
def self.ruby_pin(dir)
|
|
60
|
+
current = File.expand_path(dir)
|
|
61
|
+
loop do
|
|
62
|
+
PIN_FILES.each do |name|
|
|
63
|
+
file = File.join(current, name)
|
|
64
|
+
next unless File.file?(file)
|
|
65
|
+
|
|
66
|
+
version = parse_pin(file)
|
|
67
|
+
return { version: version, file: file, manager: manager_for(name) } if version
|
|
68
|
+
end
|
|
69
|
+
parent = File.dirname(current)
|
|
70
|
+
return nil if parent == current
|
|
71
|
+
|
|
72
|
+
current = parent
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.parse_pin(file)
|
|
77
|
+
text = File.read(file)
|
|
78
|
+
raw = case File.basename(file)
|
|
79
|
+
when '.ruby-version' then text.lines.first.to_s.strip
|
|
80
|
+
when '.tool-versions' then text[/^\s*ruby\s+(\S+)/, 1]
|
|
81
|
+
else text[/^\s*ruby\s*=\s*(?:\[\s*)?(?:\{[^}]*?version\s*=\s*)?["']([^"']+)["']/, 1]
|
|
82
|
+
end
|
|
83
|
+
raw = raw.to_s.sub(/\Aruby-/, '').strip
|
|
84
|
+
raw.empty? ? nil : raw
|
|
85
|
+
rescue SystemCallError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.manager_for(name)
|
|
90
|
+
case name
|
|
91
|
+
when 'mise.toml', '.mise.toml' then 'mise'
|
|
92
|
+
when '.tool-versions' then 'mise or asdf'
|
|
93
|
+
else 'mise, rbenv, chruby or asdf'
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# "3.4" pins any 3.4.x; "latest"/"system" pin nothing we can check.
|
|
98
|
+
def self.version_matches?(pinned, running)
|
|
99
|
+
return true unless pinned.match?(/\A\d+(\.\d+)*\z/)
|
|
100
|
+
|
|
101
|
+
running.split('.').first(pinned.split('.').length) == pinned.split('.')
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def repo_root(dir)
|
|
107
|
+
found = ProjectConfig.find_file(dir)
|
|
108
|
+
found ? File.dirname(found) : dir
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def ruby_report(dir)
|
|
112
|
+
running = RUBY_VERSION
|
|
113
|
+
pin = self.class.ruby_pin(dir)
|
|
114
|
+
mismatch = pin && !self.class.version_matches?(pin[:version], running)
|
|
115
|
+
fp_on_path = which('fp')
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
version: running,
|
|
119
|
+
ruby: RbConfig.ruby,
|
|
120
|
+
fp_version: Fp::VERSION,
|
|
121
|
+
gem_path: gem_path,
|
|
122
|
+
gem_bindir: Gem.bindir,
|
|
123
|
+
fp_on_path: fp_on_path,
|
|
124
|
+
fp_on_path_is_shim: fp_on_path&.include?('/shims/') || false,
|
|
125
|
+
pinned: pin && pin[:version],
|
|
126
|
+
pinned_by: pin && pin[:file],
|
|
127
|
+
mismatch: !!mismatch,
|
|
128
|
+
fixes: mismatch ? shim_fixes(pin, running) : []
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def shim_fixes(pin, running)
|
|
133
|
+
version = pin[:version]
|
|
134
|
+
fixes = []
|
|
135
|
+
fixes << if pin[:manager] == 'mise' || which('mise')
|
|
136
|
+
"mise exec ruby@#{version} -- gem install featureparity"
|
|
137
|
+
elsif which('rbenv')
|
|
138
|
+
'gem install featureparity && rbenv rehash # run inside this directory'
|
|
139
|
+
elsif which('asdf')
|
|
140
|
+
'gem install featureparity && asdf reshim ruby # run inside this directory'
|
|
141
|
+
else
|
|
142
|
+
"gem install featureparity # run inside this directory, under Ruby #{version}"
|
|
143
|
+
end
|
|
144
|
+
fixes << "export PATH=\"#{Gem.bindir}:$PATH\" # or keep using fp from Ruby #{running}"
|
|
145
|
+
fixes
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def gem_path
|
|
149
|
+
spec = Gem.loaded_specs['featureparity']
|
|
150
|
+
spec ? spec.full_gem_path : File.expand_path('../../..', __dir__)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def which(name)
|
|
154
|
+
ENV['PATH'].to_s.split(File::PATH_SEPARATOR).each do |entry|
|
|
155
|
+
candidate = File.join(entry, name)
|
|
156
|
+
return candidate if File.file?(candidate) && File.executable?(candidate)
|
|
157
|
+
end
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def config_report(dir)
|
|
162
|
+
r = config.resolved
|
|
163
|
+
project = ProjectConfig.discover(dir)
|
|
164
|
+
{
|
|
165
|
+
environment: r[:environment],
|
|
166
|
+
api_url: r[:api_url],
|
|
167
|
+
profile: r[:profile],
|
|
168
|
+
production: r[:environment] == 'production',
|
|
169
|
+
api_key_present: r[:api_key_present],
|
|
170
|
+
api_key_source: r[:api_key_source],
|
|
171
|
+
project_config: project.path,
|
|
172
|
+
project: project.project
|
|
173
|
+
}
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Which fp wrote this repo's AGENTS.md section, so a stale one is visible.
|
|
177
|
+
def agents_section_report(root)
|
|
178
|
+
Init::AGENT_DOC_CANDIDATES.filter_map do |name|
|
|
179
|
+
file = File.join(root, name)
|
|
180
|
+
next unless File.file?(file)
|
|
181
|
+
|
|
182
|
+
content = File.read(file)
|
|
183
|
+
next unless content.include?(Init::AGENTS_MARKER)
|
|
184
|
+
|
|
185
|
+
written = content[Init::AGENTS_VERSION_RE, 1]
|
|
186
|
+
{ path: name, written_by: written, current: written == Fp::VERSION }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def print_report(report)
|
|
191
|
+
ruby = report[:ruby]
|
|
192
|
+
cfg = report[:config]
|
|
193
|
+
repo = report[:repo]
|
|
194
|
+
|
|
195
|
+
puts "fp #{ruby[:fp_version]} on Ruby #{ruby[:version]} (#{ruby[:ruby]})"
|
|
196
|
+
puts " Gem: #{ruby[:gem_path]}"
|
|
197
|
+
puts " fp on PATH: #{ruby[:fp_on_path] || '(not found)'}#{ruby[:fp_on_path_is_shim] ? ' [version-manager shim]' : ''}"
|
|
198
|
+
if ruby[:pinned]
|
|
199
|
+
state = ruby[:mismatch] ? '⚠ differs from the Ruby running fp' : 'matches'
|
|
200
|
+
puts " Ruby pin: #{ruby[:pinned]} (#{ruby[:pinned_by]}) — #{state}"
|
|
201
|
+
end
|
|
202
|
+
if ruby[:mismatch]
|
|
203
|
+
puts
|
|
204
|
+
puts " This directory pins Ruby #{ruby[:pinned]}, but fp is installed under #{ruby[:version]}."
|
|
205
|
+
puts ' A version-manager shim runs `fp` with the pinned Ruby, where the gem is missing, and'
|
|
206
|
+
puts ' fails before fp starts (`mise ERROR No version is set for shim: fp`). Fix one of:'
|
|
207
|
+
ruby[:fixes].each { |fix| puts " #{fix}" }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
puts
|
|
211
|
+
puts 'Config'
|
|
212
|
+
env = cfg[:environment] || '(unknown)'
|
|
213
|
+
puts " Environment: #{env} (#{cfg[:api_url]})#{cfg[:profile] ? " [profile #{cfg[:profile]}]" : ''}"
|
|
214
|
+
puts " ⚠ Not production: writes go to '#{env}'. Run `fp whoami` to see why." unless cfg[:production]
|
|
215
|
+
puts " API key: #{cfg[:api_key_present] ? "present [from #{cfg[:api_key_source]}]" : 'not set (export FP_API_KEY=fp_... or fp setup)'}"
|
|
216
|
+
puts " Project file: #{cfg[:project_config] ? "#{cfg[:project_config]} (project: #{cfg[:project] || '?'})" : 'none — run `fp init --detect`, then `fp init --project <slug>`'}"
|
|
217
|
+
report[:agents_section].each do |section|
|
|
218
|
+
stamp = section[:written_by] ? "fp #{section[:written_by]}" : 'an fp that predates version stamps'
|
|
219
|
+
note = section[:current] ? '' : ' — `fp init --force` refreshes it'
|
|
220
|
+
puts " #{section[:path]}: FeatureParity section written by #{stamp}#{note}"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
puts
|
|
224
|
+
RepoDetector.print_summary(repo)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
data/lib/fp/commands/help.rb
CHANGED
|
@@ -39,6 +39,9 @@ module Fp
|
|
|
39
39
|
|
|
40
40
|
COMMANDS
|
|
41
41
|
init --project <slug> Onboard this repo: config, CI gate, agent docs
|
|
42
|
+
init --detect Suggest a slug, surfaces and test commands (writes nothing)
|
|
43
|
+
doctor Check Ruby/version-manager pins, config, and what the repo needs
|
|
44
|
+
tutorial Write Hexhaven, a sample repo to practise on
|
|
42
45
|
projects List projects you have access to
|
|
43
46
|
projects show <slug> View a project and its surfaces
|
|
44
47
|
projects create <slug> --name ... Create a project (owned by your key's owner)
|
|
@@ -57,11 +60,17 @@ module Fp
|
|
|
57
60
|
list --project <slug> List active requirements
|
|
58
61
|
show <slug> --project <slug> Show requirement details
|
|
59
62
|
propose --project <slug> ... Propose a new requirement (as draft)
|
|
63
|
+
propose --file reqs.yml Propose a batch of drafts, validated all-or-nothing
|
|
64
|
+
propose ... --dry-run Validate and lint the wording; write nothing
|
|
60
65
|
propose-change <slug> ... Propose a change to an existing requirement
|
|
61
66
|
propose-change <slug> --removal Propose deleting a requirement
|
|
62
67
|
propose-change <slug> --no-parent Propose un-nesting a child to top level
|
|
63
68
|
propose-change <slug> --withdraw Withdraw your own open proposal (to amend it)
|
|
64
69
|
comment <slug> --body "..." Comment on a requirement's open change proposal
|
|
70
|
+
question <slug> "<question>" Attach an open question for a human to a draft
|
|
71
|
+
questions <slug> [--open] List a requirement's questions and their answers
|
|
72
|
+
comments <slug> Read the team's comments on a requirement
|
|
73
|
+
comments <slug> --body "..." [--tag who,...] Comment on a requirement, tagging people or agents
|
|
65
74
|
history <slug> --project <slug> Show a requirement's change history + diffs
|
|
66
75
|
analyze [<slug>] --project <slug> Triage requirement quality (read-only; never proposes)
|
|
67
76
|
changes [--source-url URL] [--github-pr] Show or publish work-item-linked activity
|
|
@@ -71,12 +80,16 @@ module Fp
|
|
|
71
80
|
attach rm <slug> <id> Delete an attachment
|
|
72
81
|
conversation <id> Read a Pari conversation for agent review
|
|
73
82
|
report <slug> --project <slug>... Report evidence for a requirement
|
|
74
|
-
|
|
83
|
+
mark <slug> <file>:<line> Insert an fp:<slug> marker above a test (file's comment syntax)
|
|
84
|
+
report --junit <file> ... Upload evidence from a JUnit XML or RSpec JSON report (no CI)
|
|
85
|
+
run [report flags] -- <cmd...> Run rspec/pytest/vitest/playwright with a formatter added, then report
|
|
75
86
|
ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
|
|
76
87
|
ci-report --junit <file> --pr URL Ingest a PR's CI results as pr_passing — CI only
|
|
77
88
|
changed-tests List unresolved bound-test revision findings
|
|
78
89
|
changed-tests assess <id> ... Explain whether an exact revision still proves its requirement
|
|
90
|
+
spot-check [<slug>...] Check requirements' tests prove them (random, --count, --all, --test)
|
|
79
91
|
check Enforce parity; exits non-zero on violations
|
|
92
|
+
coverage [--all] Show which drafts have evidence on which surfaces (never gated)
|
|
80
93
|
matrix --project <slug> Show the parity matrix
|
|
81
94
|
export --project <slug> Export all requirements as a Markdown document
|
|
82
95
|
profile add|list Manage named profiles
|
|
@@ -159,6 +172,30 @@ module Fp
|
|
|
159
172
|
A stale or later candidate needs a new assessment. A PR candidate becomes accepted only
|
|
160
173
|
when a default-branch CI report later observes the same fingerprint.
|
|
161
174
|
|
|
175
|
+
SPOT CHECKS
|
|
176
|
+
A green receipt says a marked test ran and passed, not that it checks what the
|
|
177
|
+
requirement promises. `fp spot-check` picks an active requirement with evidence at
|
|
178
|
+
random, prints its acceptance beside the source of every locally marked test, and
|
|
179
|
+
gives the agent running it a brief: match each clause to an assertion, then break
|
|
180
|
+
the product code and confirm a test fails. Run it on a schedule or by hand.
|
|
181
|
+
|
|
182
|
+
fp spot-check # a random pick; prints the seed to repeat it
|
|
183
|
+
fp spot-check --count 5 --surface cli --json
|
|
184
|
+
fp spot-check print_container_qr print_label # chosen requirements
|
|
185
|
+
fp spot-check --all # every active requirement with evidence
|
|
186
|
+
fp spot-check --test test/qr_test.rb:42 # the test there, and its requirement
|
|
187
|
+
fp spot-check --count 3 --prompt | claude -p # hand it straight to an agent
|
|
188
|
+
fp spot-check --jev # add Jev's first read (paid plans)
|
|
189
|
+
|
|
190
|
+
`--prompt` prints the spot check as instructions for an agent, with what to report
|
|
191
|
+
back. `--jev` asks Jev, before the agent starts, whether each requirement's tests
|
|
192
|
+
appear to cover its acceptance, and which gap is likeliest. It's a hint for aiming
|
|
193
|
+
the probe, not a verdict. Jev is for paid plans: without one the spot check runs
|
|
194
|
+
without it.
|
|
195
|
+
|
|
196
|
+
It records nothing and never edits a test or a requirement. A test that stays
|
|
197
|
+
green when the promise is broken goes to a human.
|
|
198
|
+
|
|
162
199
|
AUTHENTICATION
|
|
163
200
|
Set FP_API_KEY environment variable, or use a profile:
|
|
164
201
|
|
|
@@ -422,6 +459,9 @@ module Fp
|
|
|
422
459
|
# fp parses the report, finds the fp:<slug> markers in the test files,
|
|
423
460
|
# and reports evidence for every requirement the suite covered.
|
|
424
461
|
# rspec --format RspecJunitFormatter --out junit.xml # (or your runner's equivalent)
|
|
462
|
+
# RSpec's own `--format json --out rspec.json` works as --junit too, with no extra gem.
|
|
463
|
+
# Or let fp add the formatter and report in one step (rspec, pytest, vitest, playwright):
|
|
464
|
+
# fp run --surface api --repo stowzilla/marketplace -- bundle exec rspec
|
|
425
465
|
fp report --junit junit.xml --project stowzilla \\
|
|
426
466
|
--surface api \\
|
|
427
467
|
--repo stowzilla/marketplace \\
|
data/lib/fp/commands/history.rb
CHANGED
|
@@ -31,7 +31,11 @@ module Fp
|
|
|
31
31
|
'removal_proposed' => 'deletion proposed',
|
|
32
32
|
'change_approved' => 'change approved',
|
|
33
33
|
'change_rejected' => 'change rejected',
|
|
34
|
-
'decision_recorded' => 'decision recorded'
|
|
34
|
+
'decision_recorded' => 'decision recorded',
|
|
35
|
+
'question_asked' => 'question asked',
|
|
36
|
+
'question_resolved' => 'question resolved',
|
|
37
|
+
'change_commented' => 'commented on the proposal',
|
|
38
|
+
'commented' => 'commented'
|
|
35
39
|
}.freeze
|
|
36
40
|
|
|
37
41
|
# A member's own stance on a requirement that already has a status (Fizzy #1478),
|
|
@@ -110,6 +114,9 @@ module Fp
|
|
|
110
114
|
puts "* #{label} — #{actor} (#{kind}) at #{format_time(change['recorded_at'])}"
|
|
111
115
|
puts " #{change['reason']}" if present?(change['reason'])
|
|
112
116
|
puts " note: #{change['note']}" if change['type'] == 'decision_recorded' && present?(change['note'])
|
|
117
|
+
puts " question: #{change['text']}" if present?(change['text'])
|
|
118
|
+
puts " #{change['resolution'] || 'answered'}: #{change['answer']}" if present?(change['answer'])
|
|
119
|
+
print_comment(change) if %w[commented change_commented].include?(change['type'])
|
|
113
120
|
print_source(change['source'])
|
|
114
121
|
print_diff(change['changes'])
|
|
115
122
|
print_evidence_diff(change['evidence_diff'])
|
|
@@ -117,6 +124,14 @@ module Fp
|
|
|
117
124
|
puts
|
|
118
125
|
end
|
|
119
126
|
|
|
127
|
+
# The team's comments (Fizzy #1494) and comments on a proposal, so an agent reading
|
|
128
|
+
# the history sees the discussion, not just that one happened.
|
|
129
|
+
def print_comment(change)
|
|
130
|
+
change['body'].to_s.each_line { |line| puts " > #{line.chomp}" } if present?(change['body'])
|
|
131
|
+
mentions = Array(change['mentions'])
|
|
132
|
+
puts " tagged: #{mentions.join(', ')}" unless mentions.empty?
|
|
133
|
+
end
|
|
134
|
+
|
|
120
135
|
def print_source(source)
|
|
121
136
|
return if source.nil? || source.empty?
|
|
122
137
|
|