featureparity 0.0.28 → 0.0.30

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,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
@@ -39,6 +39,8 @@ 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
42
44
  tutorial Write Hexhaven, a sample repo to practise on
43
45
  projects List projects you have access to
44
46
  projects show <slug> View a project and its surfaces
@@ -58,11 +60,15 @@ module Fp
58
60
  list --project <slug> List active requirements
59
61
  show <slug> --project <slug> Show requirement details
60
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
61
65
  propose-change <slug> ... Propose a change to an existing requirement
62
66
  propose-change <slug> --removal Propose deleting a requirement
63
67
  propose-change <slug> --no-parent Propose un-nesting a child to top level
64
68
  propose-change <slug> --withdraw Withdraw your own open proposal (to amend it)
65
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
66
72
  comments <slug> Read the team's comments on a requirement
67
73
  comments <slug> --body "..." [--tag who,...] Comment on a requirement, tagging people or agents
68
74
  history <slug> --project <slug> Show a requirement's change history + diffs
@@ -74,13 +80,16 @@ module Fp
74
80
  attach rm <slug> <id> Delete an attachment
75
81
  conversation <id> Read a Pari conversation for agent review
76
82
  report <slug> --project <slug>... Report evidence for a requirement
77
- report --junit <file> ... Upload evidence from a JUnit XML report (no CI)
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
78
86
  ci-report --junit <file> ... Ingest CI test results (passing/failing) — CI only
79
87
  ci-report --junit <file> --pr URL Ingest a PR's CI results as pr_passing — CI only
80
88
  changed-tests List unresolved bound-test revision findings
81
89
  changed-tests assess <id> ... Explain whether an exact revision still proves its requirement
82
90
  spot-check [<slug>...] Check requirements' tests prove them (random, --count, --all, --test)
83
91
  check Enforce parity; exits non-zero on violations
92
+ coverage [--all] Show which drafts have evidence on which surfaces (never gated)
84
93
  matrix --project <slug> Show the parity matrix
85
94
  export --project <slug> Export all requirements as a Markdown document
86
95
  profile add|list Manage named profiles
@@ -450,6 +459,9 @@ module Fp
450
459
  # fp parses the report, finds the fp:<slug> markers in the test files,
451
460
  # and reports evidence for every requirement the suite covered.
452
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
453
465
  fp report --junit junit.xml --project stowzilla \\
454
466
  --surface api \\
455
467
  --repo stowzilla/marketplace \\
@@ -32,6 +32,8 @@ module Fp
32
32
  'change_approved' => 'change approved',
33
33
  'change_rejected' => 'change rejected',
34
34
  'decision_recorded' => 'decision recorded',
35
+ 'question_asked' => 'question asked',
36
+ 'question_resolved' => 'question resolved',
35
37
  'change_commented' => 'commented on the proposal',
36
38
  'commented' => 'commented'
37
39
  }.freeze
@@ -112,6 +114,8 @@ module Fp
112
114
  puts "* #{label} — #{actor} (#{kind}) at #{format_time(change['recorded_at'])}"
113
115
  puts " #{change['reason']}" if present?(change['reason'])
114
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'])
115
119
  print_comment(change) if %w[commented change_commented].include?(change['type'])
116
120
  print_source(change['source'])
117
121
  print_diff(change['changes'])