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 +4 -4
- data/lib/fp/cli.rb +11 -2
- data/lib/fp/client.rb +26 -0
- data/lib/fp/commands/base.rb +14 -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/coverage.rb +149 -0
- data/lib/fp/commands/doctor.rb +228 -0
- data/lib/fp/commands/help.rb +13 -1
- data/lib/fp/commands/history.rb +4 -0
- data/lib/fp/commands/init.rb +172 -117
- data/lib/fp/commands/list.rb +58 -4
- data/lib/fp/commands/mark.rb +167 -0
- data/lib/fp/commands/projects.rb +1 -0
- data/lib/fp/commands/propose.rb +401 -52
- data/lib/fp/commands/propose_change.rb +7 -1
- data/lib/fp/commands/question.rb +137 -0
- data/lib/fp/commands/report.rb +23 -3
- data/lib/fp/commands/run.rb +147 -0
- data/lib/fp/commands/show.rb +27 -0
- data/lib/fp/commands.rb +5 -0
- data/lib/fp/junit.rb +60 -7
- 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/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +244 -105
- data/skills/fp/references/cli.md +231 -3
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5cef61c9093573a494f9d27b203554e51dfc2f0a6d60ae43e666564c8e2b1740
|
|
4
|
+
data.tar.gz: 7162500244354be8c93a78499e479ac30a7df2d25ab4f818ce3451a6819ce960
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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*,
|
data/lib/fp/commands/base.rb
CHANGED
|
@@ -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"
|
data/lib/fp/commands/check.rb
CHANGED
|
@@ -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|
|
|
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:
|
|
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.).
|
data/lib/fp/commands/comment.rb
CHANGED
|
@@ -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
|