featureparity 0.0.28 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd6b0a540d21e123a2ceb43cb9f23005c218319866ff7e079b951d6288199bb1
4
- data.tar.gz: 6e0d61e3a3d9e6f590c6e978b134736877b5abc8da2a9a80332ff6cd203dbf58
3
+ metadata.gz: 5cef61c9093573a494f9d27b203554e51dfc2f0a6d60ae43e666564c8e2b1740
4
+ data.tar.gz: 7162500244354be8c93a78499e479ac30a7df2d25ab4f818ce3451a6819ce960
5
5
  SHA512:
6
- metadata.gz: fe1d8110555996bc72c885d502139e6f18dbef1d2ee8b8623eaf48c74dce6c40b33f46ffe4b42f09c9f92570380abab9b4dff77df3520d300ec6f43f35214890
7
- data.tar.gz: 1f95d64e43e62a63411bd8cc665248777c3acdc58795e16552d4b5f671d3a1fb89a774bbf066e5f65f55bba7b716cc5f2fa78d51ea2b1a5afdc1fcd547acfa2d
6
+ metadata.gz: a4733078f1af140723dadb54a0ed5e3ba5c80b771a48ba9f8364c6dcfb088cb9e3c39c79fac27b0fd5083e2339e47b8b6fb76fe5c54fde772f68fdbc2a005e7d
7
+ data.tar.gz: 21330096e2f61cf6100dc54708c25818647f88581e0d0ef073bc84b2ec532556582012d899a663a0e96ce9e757ff6f5ae0ec51338be797bc19363f40e97d86af
data/lib/fp/cli.rb CHANGED
@@ -8,6 +8,8 @@ module Fp
8
8
  COMMANDS = {
9
9
  'init' => Commands::Init,
10
10
  'tutorial' => Commands::Tutorial,
11
+ 'doctor' => Commands::Doctor,
12
+ 'run' => Commands::Run,
11
13
  'projects' => Commands::Projects,
12
14
  'surfaces' => Commands::Surfaces,
13
15
  'showroom' => Commands::Showroom,
@@ -15,7 +17,10 @@ module Fp
15
17
  'show' => Commands::Show,
16
18
  'propose' => Commands::Propose,
17
19
  'propose-change' => Commands::ProposeChange,
20
+ 'mark' => Commands::Mark,
18
21
  'comment' => Commands::Comment,
22
+ 'question' => Commands::Question,
23
+ 'questions' => Commands::Questions,
19
24
  'comments' => Commands::Comments,
20
25
  'history' => Commands::History,
21
26
  'analyze' => Commands::Analyze,
@@ -25,6 +30,7 @@ module Fp
25
30
  'changed-tests' => Commands::ChangedTests,
26
31
  'spot-check' => Commands::SpotCheck,
27
32
  'check' => Commands::Check,
33
+ 'coverage' => Commands::Coverage,
28
34
  'matrix' => Commands::Matrix,
29
35
  'export' => Commands::Export,
30
36
  'profile' => Commands::Profile,
@@ -42,6 +48,9 @@ module Fp
42
48
  class << self
43
49
  def run(argv)
44
50
  args = argv.dup
51
+ # Everything after `--` belongs to another program (`fp run -- pytest -p no:cov`),
52
+ # so global flags and --help are only looked for before it.
53
+ passthrough = (split = args.index('--')) ? args.slice!(split..) : []
45
54
  global_opts = extract_global_options(args)
46
55
 
47
56
  command_name = args.shift || 'help'
@@ -71,7 +80,7 @@ module Fp
71
80
 
72
81
  # Profile/config commands don't need auth. `init` is here too: it only writes
73
82
  # files into the repo, and onboarding a repo must work before anyone has a key.
74
- needs_auth = !%w[init profile config repos setup whoami version help].include?(command_name)
83
+ needs_auth = !%w[init doctor profile config repos setup whoami version help mark].include?(command_name)
75
84
 
76
85
  if needs_auth
77
86
  config = Config.new(profile: global_opts[:profile], api_url: global_opts[:api_url])
@@ -87,7 +96,7 @@ module Fp
87
96
 
88
97
  output = Output.new(json: global_opts[:json])
89
98
  command = command_class.new(client: client, output: output, config: config)
90
- command.run(args)
99
+ command.run(args + passthrough)
91
100
  rescue Interrupt
92
101
  warn "\nAborted."
93
102
  exit 130
data/lib/fp/client.rb CHANGED
@@ -247,6 +247,21 @@ module Fp
247
247
  post("/api/requirements/#{requirement_id}/changes/#{change_id}/comment", params)
248
248
  end
249
249
 
250
+ # GET /requirements/:id/questions
251
+ #
252
+ # The questions left for a human on a requirement (Fizzy #1786), open and resolved.
253
+ def list_requirement_questions(requirement_id)
254
+ get("/api/requirements/#{requirement_id}/questions")
255
+ end
256
+
257
+ # POST /requirements/:id/questions
258
+ #
259
+ # Attach an open question for a human to a draft (or active) requirement. There is
260
+ # no resolve method: only a human answers or dismisses, from the web app.
261
+ def ask_requirement_question(requirement_id, text)
262
+ post("/api/requirements/#{requirement_id}/questions", { text: text })
263
+ end
264
+
250
265
  # GET /requirements/:id/comments
251
266
  #
252
267
  # The team's comments on a requirement, oldest first, plus who can be tagged:
@@ -367,6 +382,17 @@ module Fp
367
382
  get("/api/projects/#{project_id}/parity", params)
368
383
  end
369
384
 
385
+ # GET /projects/:project_id/parity?include_drafts=true — the same report plus a
386
+ # top-level `drafts` array: never-approved drafts with their cells resolved from
387
+ # evidence. Informational only; the server never gates on it (Fizzy #1786). Read by
388
+ # `fp coverage`, `fp list --status draft --gaps` and `fp check --include-drafts`.
389
+ def get_parity_with_drafts(project_id, level: nil, surface: nil)
390
+ params = { include_drafts: 'true' }
391
+ params[:level] = level if level
392
+ params[:surface] = surface if surface
393
+ get("/api/projects/#{project_id}/parity", params)
394
+ end
395
+
370
396
  # POST /projects/:project_id/parity/preview — the pull-request gate.
371
397
  #
372
398
  # Evaluates JUnit results as an overlay on the stored grid *without persisting them*,
@@ -210,6 +210,20 @@ module Fp
210
210
  project['id']
211
211
  end
212
212
 
213
+ # One line on stderr before a command writes to a project, when the API it's about
214
+ # to write to isn't production. FP_PROFILE / FP_API_URL left over in a shell put an
215
+ # agent's new project on a dev environment with nothing to say so (Fizzy #1786).
216
+ # Stderr keeps --json output parseable; it never stops the command.
217
+ def warn_if_not_production!
218
+ env = config&.environment
219
+ return if env.nil? || env == 'production'
220
+
221
+ profile = config.profile_name
222
+ via = profile ? " (profile '#{profile}')" : ''
223
+ $stderr.puts "⚠ Writing to the '#{env}' environment at #{config.api_url}#{via}, not production. " \
224
+ 'Run `fp whoami` to see where that comes from.'
225
+ end
226
+
213
227
  def require_flag(opts, flag, message = nil)
214
228
  unless opts[flag]
215
229
  message ||= "--#{flag.to_s.tr('_', '-')} is required"
@@ -51,15 +51,17 @@ module Fp
51
51
  help_flag :surface, 'Gate a single surface'
52
52
  help_flag :test_type, 'Gate a single test type'
53
53
  help_flag :level, 'Override the enforcement level for this run (off/regression/covered/strict)'
54
- help_flag :junit, "Gate the branch on this run's results, overlaid on the grid (repeatable, one per surface)"
54
+ help_flag :junit, "Gate the branch on this run's results (JUnit XML or RSpec JSON), overlaid on the grid (repeatable, one per surface)"
55
55
  help_flag :base_dir, "Where the report's relative test-file paths resolve from"
56
56
  help_flag :pr, 'With --junit: this run is a pull request — fail only on parity it makes worse, ' \
57
57
  'not on what the default branch already owes (optionally the PR URL)'
58
58
  help_flag :warn_only, 'Report violations but still exit 0'
59
+ help_flag :include_drafts, 'Also show draft coverage after the verdict (never changes the exit code)'
59
60
  help_example 'fp check', 'Gate on recorded state (project from .featureparity.yml)'
60
61
  help_example 'fp check --surface api --junit junit.xml', 'Gate a default-branch run on its own results'
61
62
  help_example 'fp check --surface api --junit junit.xml --pr', 'Gate a pull request on what it changes'
62
63
  help_example 'fp check --warn-only --json'
64
+ help_example 'fp check --include-drafts', 'Onboarding: which drafts have evidence, which have none'
63
65
  help_note "Exit 0 = passed, 1 = violations (or request failed). The level lives on the project\n" \
64
66
  'server-side; --level only overrides this one run.'
65
67
 
@@ -73,7 +75,8 @@ module Fp
73
75
  # `--pr` alone is true; `--pr <url>` carries the URL. Not :boolean on purpose, so
74
76
  # the parser takes a following value when one is given.
75
77
  pr: :string,
76
- warn_only: :boolean
78
+ warn_only: :boolean,
79
+ include_drafts: :boolean
77
80
  }.freeze
78
81
 
79
82
  # How many inherited violations to list before summarising the rest. They are
@@ -103,7 +106,9 @@ module Fp
103
106
  project_id = resolve_project_id(project_slug)
104
107
 
105
108
  result =
106
- if junit_paths.empty?
109
+ if junit_paths.empty? && opts[:include_drafts]
110
+ client.get_parity_with_drafts(project_id, level: level, surface: surface)
111
+ elsif junit_paths.empty?
107
112
  client.get_parity(project_id, level: level, surface: surface)
108
113
  else
109
114
  preview(project_id, junit_paths, surface, level, opts)
@@ -125,7 +130,12 @@ module Fp
125
130
  'this verdict counts every violation, including those the default branch already has.'
126
131
  end
127
132
 
133
+ # Draft coverage rides along when asked for. Informational only: it is added after
134
+ # the verdict was decided and never feeds `passed` (Fizzy #1786).
135
+ report = report.merge('drafts' => draft_rows(project_id, level, surface, report)) if opts[:include_drafts]
136
+
128
137
  render(report, violations, passed, project_slug, opts)
138
+ print_drafts(report) if opts[:include_drafts] && !output.json?
129
139
 
130
140
  # A failing gate is the whole point of the command, so it must be the exit code
131
141
  # and not just something printed. --warn-only downgrades it to advisory.
@@ -134,6 +144,30 @@ module Fp
134
144
 
135
145
  private
136
146
 
147
+ # The standing read already carries `drafts` when asked for. The preview endpoint
148
+ # doesn't, so a --junit run reads them from the standing report instead; drafts are
149
+ # recorded state either way. A failed or too-old read is a warning, never a failed
150
+ # gate, because drafts can't change the verdict.
151
+ def draft_rows(project_id, level, surface, report)
152
+ return report['drafts'] if report.key?('drafts')
153
+
154
+ result = client.get_parity_with_drafts(project_id, level: level, surface: surface)
155
+ drafts = result[:ok] ? (result[:data]['parity'] || {})['drafts'] : nil
156
+ if drafts.nil?
157
+ warn 'Warning: draft coverage is unavailable from this API; the verdict is unaffected.'
158
+ return []
159
+ end
160
+ drafts
161
+ end
162
+
163
+ def print_drafts(report)
164
+ drafts = report['drafts'] || []
165
+ puts
166
+ puts "Draft coverage (#{drafts.size} draft(s), not gated):"
167
+ Coverage.print_drafts(output, drafts, report['surfaces'] || [])
168
+ Coverage.print_untested(Coverage.untested(drafts)) unless drafts.empty?
169
+ end
170
+
137
171
  # Preview mode needs to know which surface the results belong to, because the
138
172
  # server uses it to decide which cells the run had a chance to cover — and
139
173
  # therefore which vanished tests it may legitimately flag.
@@ -162,7 +196,7 @@ module Fp
162
196
  client.preview_parity(
163
197
  project_id,
164
198
  surface: surface,
165
- junit_xmls: junit_paths.map { |p| File.read(p) },
199
+ junit_xmls: junit_paths.map { |p| Fp::JUnit.read_xml(p) },
166
200
  level: level,
167
201
  results: resolve_results(junit_paths, surface, opts),
168
202
  pull_request: !!opts[:pr],
@@ -57,7 +57,7 @@ module Fp
57
57
  help_flag :test_type, 'Default test kind for markers that pin none'
58
58
  help_flag :ci_url, 'CI run URL to record on the evidence'
59
59
  help_flag :pr, "Mark this as a PR's CI: passing tests recorded as pr_passing, no merged evidence demoted"
60
- help_flag :junit, 'JUnit XML report (repeatable to combine a surface split across shards)'
60
+ help_flag :junit, 'JUnit XML report, or RSpec `--format json` output (repeatable to combine a surface split across shards)'
61
61
  help_flag :base_dir, "Where the report's relative test-file paths resolve from (e.g. frontend)"
62
62
  help_flag :repo, 'Repository the tests live in (org/repo)'
63
63
  help_flag :sha, 'Checkout commit SHA'
@@ -127,7 +127,7 @@ module Fp
127
127
  totals[:unmarked_failures].concat(resolved[:unmarked_failures])
128
128
  totals[:test_revisions] += test_revisions.length
129
129
 
130
- { path: path, xml: File.read(path), results: results, test_revisions: test_revisions }
130
+ { path: path, xml: Fp::JUnit.read_xml(path), results: results, test_revisions: test_revisions }
131
131
  end
132
132
 
133
133
  # One surface run may be split across several reports (unit + e2e, shards, etc.).
@@ -17,7 +17,7 @@ module Fp
17
17
  source_type: :string, source_url: :string, source_context: :string
18
18
  }.freeze
19
19
 
20
- VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat].freeze
20
+ VALID_SOURCE_TYPES = %w[fizzy discord github_issue jira notion manual ai_chat repo_scan].freeze
21
21
 
22
22
  help_summary "Comment on a requirement's open change proposal"
23
23
  help_usage 'fp comment <slug> --body "..." --project <slug>'
@@ -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