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.
- checksums.yaml +4 -4
- data/lib/fp/cli.rb +11 -2
- data/lib/fp/client.rb +31 -1
- data/lib/fp/commands/base.rb +14 -0
- data/lib/fp/commands/check.rb +38 -4
- data/lib/fp/commands/ci_report.rb +35 -5
- 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 +10 -2
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
|
|
6
|
+
module Fp
|
|
7
|
+
# Reads a checkout and says what it is, for `fp init --detect` and `fp doctor`
|
|
8
|
+
# (Fizzy #1786): the frameworks and test runners it uses, the exact command that makes
|
|
9
|
+
# each runner emit a report fp can read (and whether that needs a dependency added), a
|
|
10
|
+
# project slug, the surfaces a user would touch, whether the tests need a database, and
|
|
11
|
+
# whether there's a devcontainer.
|
|
12
|
+
#
|
|
13
|
+
# Purely local and read-only: it looks at manifests (Gemfile, package.json, Podfile,
|
|
14
|
+
# pyproject, Android manifests, Rails routes) in the root and one directory down, which
|
|
15
|
+
# covers the common `frontend/` + `backend/` monorepo without walking node_modules. It
|
|
16
|
+
# suggests; the human picks. Nothing here is written anywhere.
|
|
17
|
+
class RepoDetector
|
|
18
|
+
# What makes something a surface. Printed beside every suggestion, because the
|
|
19
|
+
# suggestion is only a starting point and this is the rule to judge it by.
|
|
20
|
+
SURFACE_RUBRIC = 'A surface is somewhere a user interacts with the product: a web app, ' \
|
|
21
|
+
'an iOS or Android app, a desktop app, a CLI, or an API that customers ' \
|
|
22
|
+
'call directly. An internal API with no separate client is not one: ' \
|
|
23
|
+
'its tests are evidence for the surface the user touches.'
|
|
24
|
+
|
|
25
|
+
SKIP_DIRS = %w[node_modules vendor tmp log coverage dist build .git .bundle Pods].freeze
|
|
26
|
+
|
|
27
|
+
# Database drivers that need a running server. SQLite is a file, so it's reported but
|
|
28
|
+
# never makes `needed` true.
|
|
29
|
+
RUBY_DB_GEMS = { 'pg' => 'postgresql', 'mysql2' => 'mysql', 'trilogy' => 'mysql',
|
|
30
|
+
'mongoid' => 'mongodb', 'sqlite3' => 'sqlite' }.freeze
|
|
31
|
+
JS_DB_PACKAGES = { 'pg' => 'postgresql', 'postgres' => 'postgresql', 'mysql' => 'mysql',
|
|
32
|
+
'mysql2' => 'mysql', 'mongodb' => 'mongodb', 'mongoose' => 'mongodb',
|
|
33
|
+
'better-sqlite3' => 'sqlite', 'sqlite3' => 'sqlite' }.freeze
|
|
34
|
+
PY_DB_PACKAGES = { 'psycopg2' => 'postgresql', 'psycopg2-binary' => 'postgresql',
|
|
35
|
+
'psycopg' => 'postgresql', 'asyncpg' => 'postgresql',
|
|
36
|
+
'mysqlclient' => 'mysql', 'pymysql' => 'mysql', 'pymongo' => 'mongodb' }.freeze
|
|
37
|
+
DB_ADAPTERS = { 'postgresql' => 'postgresql', 'postgis' => 'postgresql', 'mysql2' => 'mysql',
|
|
38
|
+
'trilogy' => 'mysql', 'sqlite3' => 'sqlite' }.freeze
|
|
39
|
+
COMPOSE_IMAGES = { 'postgres' => 'postgresql', 'postgis' => 'postgresql', 'mysql' => 'mysql',
|
|
40
|
+
'mariadb' => 'mysql', 'mongo' => 'mongodb' }.freeze
|
|
41
|
+
SERVER_DATABASES = %w[postgresql mysql mongodb].freeze
|
|
42
|
+
|
|
43
|
+
COMPOSE_FILES = %w[docker-compose.yml docker-compose.yaml compose.yml compose.yaml].freeze
|
|
44
|
+
|
|
45
|
+
attr_reader :root
|
|
46
|
+
|
|
47
|
+
def initialize(root = Dir.pwd)
|
|
48
|
+
@root = File.expand_path(root)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Everything, as one plain hash (string keys, JSON-ready).
|
|
52
|
+
def detect
|
|
53
|
+
@detect ||= begin
|
|
54
|
+
dirs = scan_dirs
|
|
55
|
+
frameworks = []
|
|
56
|
+
runners = []
|
|
57
|
+
surfaces = []
|
|
58
|
+
databases = []
|
|
59
|
+
dirs.each { |dir| inspect_dir(dir, frameworks, runners, surfaces, databases) }
|
|
60
|
+
databases.concat(compose_databases)
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
'root' => root,
|
|
64
|
+
'slug' => suggested_slug,
|
|
65
|
+
'repo' => git_remote_repo,
|
|
66
|
+
'frameworks' => frameworks.uniq,
|
|
67
|
+
'test_runners' => runners.uniq { |r| [r['name'], r['dir']] },
|
|
68
|
+
'surfaces' => merge_surfaces(surfaces),
|
|
69
|
+
'surface_rubric' => SURFACE_RUBRIC,
|
|
70
|
+
'database' => database_summary(databases),
|
|
71
|
+
'devcontainer' => devcontainer_path
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# "org/repo" from the origin remote, when there is one.
|
|
77
|
+
def git_remote_repo
|
|
78
|
+
url = git_remote_url
|
|
79
|
+
return nil unless url
|
|
80
|
+
|
|
81
|
+
match = url.match(%r{[:/]([^/:]+/[^/]+?)(?:\.git)?/?\z})
|
|
82
|
+
match && match[1]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def suggested_slug
|
|
86
|
+
repo = git_remote_repo
|
|
87
|
+
self.class.slugify(repo ? repo.split('/').last : File.basename(root))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.slugify(name)
|
|
91
|
+
name.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/\A-+|-+\z/, '')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The one-line database note, shared by doctor, `fp init --detect` and init's guidance.
|
|
95
|
+
def self.database_note(database)
|
|
96
|
+
return nil unless database && database['needed']
|
|
97
|
+
|
|
98
|
+
kinds = database['kinds'] & SERVER_DATABASES
|
|
99
|
+
"The tests need a #{kinds.join('/')} server (#{database['evidence'].first(3).join('; ')}). " \
|
|
100
|
+
"The generated CI workflow has no database service: add one to its report job before the tests run there."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Human-readable summary of #detect, printed by `fp doctor` and `fp init --detect`.
|
|
104
|
+
def self.print_summary(repo, out: $stdout)
|
|
105
|
+
out.puts 'Repo'
|
|
106
|
+
out.puts " Suggested slug: #{repo['slug']}#{repo['repo'] ? " (from git remote #{repo['repo']})" : ' (from the directory name)'}"
|
|
107
|
+
frameworks = repo['frameworks'].map { |f| f['dir'] == '.' ? f['name'] : "#{f['name']} (#{f['dir']})" }
|
|
108
|
+
out.puts " Frameworks: #{frameworks.empty? ? '(none recognised)' : frameworks.uniq.join(', ')}"
|
|
109
|
+
|
|
110
|
+
if repo['surfaces'].empty?
|
|
111
|
+
out.puts ' Surfaces: (none suggested)'
|
|
112
|
+
else
|
|
113
|
+
out.puts ' Surfaces:'
|
|
114
|
+
repo['surfaces'].each do |s|
|
|
115
|
+
out.puts " #{s['key'].ljust(8)} #{s['reasons'].join('; ')}"
|
|
116
|
+
out.puts " #{s['note']}" if s['note']
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
out.puts " Rubric: #{repo['surface_rubric']}"
|
|
120
|
+
|
|
121
|
+
if repo['test_runners'].empty?
|
|
122
|
+
out.puts ' Test runners: (none recognised) — any runner that writes JUnit XML works with `fp report --junit`'
|
|
123
|
+
else
|
|
124
|
+
out.puts ' Test runners:'
|
|
125
|
+
repo['test_runners'].each do |r|
|
|
126
|
+
where = r['dir'] == '.' ? '' : " (#{r['dir']})"
|
|
127
|
+
out.puts " #{r['name']}#{where}"
|
|
128
|
+
out.puts " report: #{r['report_command']}"
|
|
129
|
+
out.puts " needs: #{r['dependency'] ? "add #{r['dependency']}" : 'nothing added to the project'}"
|
|
130
|
+
out.puts " or: #{r['alternative']}" if r['alternative']
|
|
131
|
+
out.puts " fp run: #{r['fp_run']}" if r['fp_run']
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
db = repo['database']
|
|
136
|
+
out.puts " Database: #{db['needed'] ? "needed — #{(db['kinds'] & SERVER_DATABASES).join(', ')}" : (db['kinds'].empty? ? 'none detected' : "#{db['kinds'].join(', ')} (no server needed)")}"
|
|
137
|
+
db['evidence'].each { |e| out.puts " #{e}" } if db['needed']
|
|
138
|
+
out.puts " Devcontainer: #{repo['devcontainer'] || 'none'}#{repo['devcontainer'] && db['needed'] ? ' (it may already provide the database)' : ''}"
|
|
139
|
+
note = database_note(db)
|
|
140
|
+
out.puts " Note: #{note}" if note
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
# The root and its immediate subdirectories that hold a manifest.
|
|
146
|
+
def scan_dirs
|
|
147
|
+
subdirs = Dir.children(root).sort.select do |name|
|
|
148
|
+
!name.start_with?('.') && !SKIP_DIRS.include?(name) && File.directory?(File.join(root, name))
|
|
149
|
+
end
|
|
150
|
+
['.'] + subdirs
|
|
151
|
+
rescue SystemCallError
|
|
152
|
+
['.']
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def path(dir, *parts)
|
|
156
|
+
File.join(root, dir, *parts)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def file?(dir, *parts)
|
|
160
|
+
File.file?(path(dir, *parts))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def read(dir, *parts)
|
|
164
|
+
File.read(path(dir, *parts))
|
|
165
|
+
rescue SystemCallError
|
|
166
|
+
''
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def label(dir, file)
|
|
170
|
+
dir == '.' ? file : File.join(dir, file)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def inspect_dir(dir, frameworks, runners, surfaces, databases)
|
|
174
|
+
inspect_ruby(dir, frameworks, runners, surfaces, databases) if file?(dir, 'Gemfile')
|
|
175
|
+
inspect_js(dir, frameworks, runners, surfaces, databases) if file?(dir, 'package.json')
|
|
176
|
+
inspect_python(dir, frameworks, runners, surfaces, databases) if python?(dir)
|
|
177
|
+
inspect_ios(dir, frameworks, runners, surfaces)
|
|
178
|
+
inspect_android(dir, frameworks, runners, surfaces)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# --- Ruby ---------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def ruby_gems(dir)
|
|
184
|
+
gems = read(dir, 'Gemfile').scan(/^\s*gem\s+['"]([^'"]+)['"]/).flatten
|
|
185
|
+
# Gemfile.lock's specs carry transitive gems too (rspec-core under rspec-rails).
|
|
186
|
+
gems += read(dir, 'Gemfile.lock').scan(/^ {4}([a-zA-Z0-9_.-]+) \(/).flatten
|
|
187
|
+
gemspec = Dir.glob(path(dir, '*.gemspec')).first
|
|
188
|
+
gems += File.read(gemspec).scan(/add_(?:development_)?dependency\s*\(?\s*['"]([^'"]+)['"]/).flatten if gemspec
|
|
189
|
+
gems.uniq
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def inspect_ruby(dir, frameworks, runners, surfaces, databases)
|
|
193
|
+
gems = ruby_gems(dir)
|
|
194
|
+
|
|
195
|
+
if gems.include?('rails') || gems.include?('railties')
|
|
196
|
+
api_only = read(dir, 'config', 'application.rb').match?(/config\.api_only\s*=\s*true/)
|
|
197
|
+
frameworks << { 'name' => 'rails', 'dir' => dir, 'evidence' => label(dir, 'Gemfile') }
|
|
198
|
+
if api_only
|
|
199
|
+
surfaces << api_surface(dir, 'Rails app with config.api_only')
|
|
200
|
+
elsif rails_views?(dir)
|
|
201
|
+
surfaces << surface('web', dir, "Rails app with views (#{label(dir, 'app/views')})")
|
|
202
|
+
if read(dir, 'config', 'routes.rb').match?(/namespace\s+:api\b|scope\s+['"]\/?api/)
|
|
203
|
+
surfaces << api_surface(dir, "#{label(dir, 'config/routes.rb')} has an api namespace")
|
|
204
|
+
end
|
|
205
|
+
else
|
|
206
|
+
surfaces << api_surface(dir, 'Rails app with no views')
|
|
207
|
+
end
|
|
208
|
+
elsif gems.include?('sinatra') || gems.include?('roda') || gems.include?('hanami')
|
|
209
|
+
name = (%w[sinatra roda hanami] & gems).first
|
|
210
|
+
frameworks << { 'name' => name, 'dir' => dir, 'evidence' => label(dir, 'Gemfile') }
|
|
211
|
+
surfaces << api_surface(dir, "#{name} app")
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
gemspec = Dir.glob(path(dir, '*.gemspec')).first
|
|
215
|
+
if gemspec && File.read(gemspec).match?(/\.executables\s*=/)
|
|
216
|
+
surfaces << surface('cli', dir, "#{label(dir, File.basename(gemspec))} ships executables")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
rails = frameworks.any? { |f| f['name'] == 'rails' && f['dir'] == dir }
|
|
220
|
+
if gems.include?('rspec-core') || gems.include?('rspec-rails') || gems.include?('rspec') ||
|
|
221
|
+
file?(dir, '.rspec') || File.directory?(path(dir, 'spec'))
|
|
222
|
+
runners << rspec_runner(dir, gems)
|
|
223
|
+
end
|
|
224
|
+
if (rails && !gems.include?('rspec-rails')) || gems.include?('minitest') ||
|
|
225
|
+
Dir.glob(path(dir, 'test', '**', '*_test.rb')).any?
|
|
226
|
+
runners << minitest_runner(dir, gems, rails)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
gems.each do |gem|
|
|
230
|
+
databases << { 'kind' => RUBY_DB_GEMS[gem], 'evidence' => "#{label(dir, 'Gemfile')}: #{gem}" } if RUBY_DB_GEMS[gem]
|
|
231
|
+
end
|
|
232
|
+
adapters = read(dir, 'config', 'database.yml').scan(/^\s*adapter:\s*["']?([a-z0-9_]+)/).flatten.uniq
|
|
233
|
+
adapters.each do |adapter|
|
|
234
|
+
kind = DB_ADAPTERS[adapter]
|
|
235
|
+
databases << { 'kind' => kind, 'evidence' => "#{label(dir, 'config/database.yml')}: adapter #{adapter}" } if kind
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def rails_views?(dir)
|
|
240
|
+
Dir.glob(path(dir, 'app', 'views', '**', '*')).any? do |f|
|
|
241
|
+
File.file?(f) && !f.include?('/layouts/') && !f.match?(%r{/views/[^/]*mailer})
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def rspec_runner(dir, gems)
|
|
246
|
+
prefix = gems.include?('rspec-core') || gems.include?('rspec-rails') ? 'bundle exec ' : ''
|
|
247
|
+
command = "#{prefix}rspec"
|
|
248
|
+
runner('rspec', dir,
|
|
249
|
+
command: command,
|
|
250
|
+
report_command: "#{command} --format progress --format json --out rspec.json",
|
|
251
|
+
report_file: 'rspec.json',
|
|
252
|
+
dependency: nil,
|
|
253
|
+
alternative: "#{command} --format RspecJunitFormatter --out junit.xml " \
|
|
254
|
+
"(needs gem 'rspec_junit_formatter' in the Gemfile's test group)",
|
|
255
|
+
fp_run: true)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def minitest_runner(dir, gems, rails)
|
|
259
|
+
command = rails ? 'bin/rails test' : 'bundle exec rake test'
|
|
260
|
+
report = rails ? "#{command} --junit --junit-filename=junit.xml" : "#{command} TESTOPTS=\"--junit --junit-filename=junit.xml\""
|
|
261
|
+
runner('minitest', dir,
|
|
262
|
+
command: command,
|
|
263
|
+
report_command: report,
|
|
264
|
+
report_file: 'junit.xml',
|
|
265
|
+
dependency: gems.include?('minitest-junit') ? nil : "gem 'minitest-junit', group: :test",
|
|
266
|
+
fp_run: false)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# --- JavaScript -----------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def inspect_js(dir, frameworks, runners, surfaces, databases)
|
|
272
|
+
pkg = begin
|
|
273
|
+
JSON.parse(read(dir, 'package.json'))
|
|
274
|
+
rescue JSON::ParserError
|
|
275
|
+
{}
|
|
276
|
+
end
|
|
277
|
+
deps = (pkg['dependencies'] || {}).merge(pkg['devDependencies'] || {}).keys
|
|
278
|
+
evidence = label(dir, 'package.json')
|
|
279
|
+
|
|
280
|
+
mobile = (deps & %w[react-native expo]).first
|
|
281
|
+
web = (deps & %w[next nuxt @remix-run/react @sveltejs/kit svelte vue @angular/core react vite]).first
|
|
282
|
+
server = (deps & %w[express fastify koa @nestjs/core hono]).first
|
|
283
|
+
|
|
284
|
+
[mobile, web, server, ('electron' if deps.include?('electron'))].compact.each do |name|
|
|
285
|
+
frameworks << { 'name' => name, 'dir' => dir, 'evidence' => evidence }
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
if mobile
|
|
289
|
+
surfaces << surface('ios', dir, "#{mobile} app (#{evidence})")
|
|
290
|
+
surfaces << surface('android', dir, "#{mobile} app (#{evidence})")
|
|
291
|
+
elsif deps.include?('electron')
|
|
292
|
+
surfaces << surface('desktop', dir, "electron app (#{evidence})")
|
|
293
|
+
elsif web
|
|
294
|
+
surfaces << surface('web', dir, "#{web} front end (#{evidence})")
|
|
295
|
+
elsif server
|
|
296
|
+
surfaces << api_surface(dir, "#{server} server (#{evidence})")
|
|
297
|
+
end
|
|
298
|
+
surfaces << surface('cli', dir, "#{evidence} declares a bin") if pkg['bin'] && !web && !mobile
|
|
299
|
+
|
|
300
|
+
runner_prefix = npx_for(dir)
|
|
301
|
+
if deps.include?('vitest')
|
|
302
|
+
runners << runner('vitest', dir,
|
|
303
|
+
command: "#{runner_prefix} vitest run",
|
|
304
|
+
report_command: "#{runner_prefix} vitest run --reporter=default --reporter=junit --outputFile.junit=junit.xml",
|
|
305
|
+
report_file: 'junit.xml', dependency: nil, fp_run: true)
|
|
306
|
+
end
|
|
307
|
+
if deps.include?('jest')
|
|
308
|
+
runners << runner('jest', dir,
|
|
309
|
+
command: "#{runner_prefix} jest",
|
|
310
|
+
report_command: "JEST_JUNIT_OUTPUT_FILE=junit.xml #{runner_prefix} jest --ci --reporters=default --reporters=jest-junit",
|
|
311
|
+
report_file: 'junit.xml',
|
|
312
|
+
dependency: deps.include?('jest-junit') ? nil : 'jest-junit (devDependency)',
|
|
313
|
+
fp_run: false)
|
|
314
|
+
end
|
|
315
|
+
if deps.include?('@playwright/test')
|
|
316
|
+
runners << runner('playwright', dir,
|
|
317
|
+
command: "#{runner_prefix} playwright test",
|
|
318
|
+
report_command: "PLAYWRIGHT_JUNIT_OUTPUT_NAME=junit.xml #{runner_prefix} playwright test --reporter=list,junit",
|
|
319
|
+
report_file: 'junit.xml', dependency: nil, fp_run: true)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
deps.each do |dep|
|
|
323
|
+
databases << { 'kind' => JS_DB_PACKAGES[dep], 'evidence' => "#{evidence}: #{dep}" } if JS_DB_PACKAGES[dep]
|
|
324
|
+
end
|
|
325
|
+
provider = read(dir, 'prisma', 'schema.prisma')[/provider\s*=\s*"(postgresql|mysql|mongodb|sqlite)"/, 1]
|
|
326
|
+
databases << { 'kind' => provider, 'evidence' => "#{label(dir, 'prisma/schema.prisma')}: #{provider}" } if provider
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def npx_for(dir)
|
|
330
|
+
return 'pnpm exec' if file?(dir, 'pnpm-lock.yaml')
|
|
331
|
+
return 'yarn' if file?(dir, 'yarn.lock')
|
|
332
|
+
return 'bunx' if file?(dir, 'bun.lockb') || file?(dir, 'bun.lock')
|
|
333
|
+
|
|
334
|
+
'npx'
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# --- Python ---------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
PY_MANIFESTS = %w[pyproject.toml requirements.txt requirements-dev.txt setup.cfg setup.py Pipfile].freeze
|
|
340
|
+
|
|
341
|
+
def python?(dir)
|
|
342
|
+
PY_MANIFESTS.any? { |f| file?(dir, f) } || file?(dir, 'pytest.ini')
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def inspect_python(dir, frameworks, runners, surfaces, databases)
|
|
346
|
+
text = PY_MANIFESTS.map { |f| read(dir, f) }.join("\n").downcase
|
|
347
|
+
has = ->(name) { text.match?(/(?:^|["'\s\[,])#{Regexp.escape(name)}(?:[\s"'<>=~!\[;,]|$)/) }
|
|
348
|
+
|
|
349
|
+
framework = %w[django fastapi flask].find { |name| has.call(name) }
|
|
350
|
+
if framework
|
|
351
|
+
frameworks << { 'name' => framework, 'dir' => dir, 'evidence' => label(dir, 'pyproject/requirements') }
|
|
352
|
+
if framework == 'django' && Dir.glob(path(dir, '**', 'templates')).any?
|
|
353
|
+
surfaces << surface('web', dir, 'Django app with templates')
|
|
354
|
+
else
|
|
355
|
+
surfaces << api_surface(dir, "#{framework} app")
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
if has.call('pytest') || file?(dir, 'pytest.ini') || file?(dir, 'conftest.py')
|
|
360
|
+
runners << runner('pytest', dir,
|
|
361
|
+
command: 'pytest',
|
|
362
|
+
report_command: 'pytest -o junit_family=xunit1 --junitxml=junit.xml',
|
|
363
|
+
report_file: 'junit.xml', dependency: nil, fp_run: true)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
PY_DB_PACKAGES.each do |pkg, kind|
|
|
367
|
+
databases << { 'kind' => kind, 'evidence' => "#{label(dir, 'requirements')}: #{pkg}" } if has.call(pkg)
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# --- Mobile ---------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
def inspect_ios(dir, frameworks, runners, surfaces)
|
|
374
|
+
xcode = Dir.glob(path(dir, '*.{xcodeproj,xcworkspace}')).first
|
|
375
|
+
podfile = file?(dir, 'Podfile')
|
|
376
|
+
spm = file?(dir, 'Package.swift')
|
|
377
|
+
return unless xcode || podfile || spm
|
|
378
|
+
|
|
379
|
+
evidence = xcode ? label(dir, File.basename(xcode)) : label(dir, podfile ? 'Podfile' : 'Package.swift')
|
|
380
|
+
frameworks << { 'name' => 'ios', 'dir' => dir, 'evidence' => evidence }
|
|
381
|
+
surfaces << surface('ios', dir, "Xcode project (#{evidence})") if xcode || podfile
|
|
382
|
+
|
|
383
|
+
if xcode || podfile
|
|
384
|
+
runners << runner('xctest', dir,
|
|
385
|
+
command: 'xcodebuild test -scheme <Scheme> -destination "platform=iOS Simulator,name=iPhone 16"',
|
|
386
|
+
report_command: 'xcodebuild test -scheme <Scheme> -destination "platform=iOS Simulator,name=iPhone 16" ' \
|
|
387
|
+
'| xcbeautify --report junit --report-path . --junit-report-filename junit.xml',
|
|
388
|
+
report_file: 'junit.xml',
|
|
389
|
+
dependency: 'xcbeautify (brew install xcbeautify)', fp_run: false)
|
|
390
|
+
else
|
|
391
|
+
runners << runner('xctest', dir, command: 'swift test',
|
|
392
|
+
report_command: 'swift test --xunit-output junit.xml',
|
|
393
|
+
report_file: 'junit.xml', dependency: nil, fp_run: false)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def inspect_android(dir, frameworks, runners, surfaces)
|
|
398
|
+
manifests = Dir.glob(path(dir, '{,*/}src/main/AndroidManifest.xml'))
|
|
399
|
+
return if manifests.empty?
|
|
400
|
+
|
|
401
|
+
evidence = manifests.first.delete_prefix("#{root}/")
|
|
402
|
+
frameworks << { 'name' => 'android', 'dir' => dir, 'evidence' => evidence }
|
|
403
|
+
surfaces << surface('android', dir, "Android manifest (#{evidence})")
|
|
404
|
+
runners << runner('gradle', dir,
|
|
405
|
+
command: './gradlew test',
|
|
406
|
+
report_command: './gradlew test # writes JUnit XML to */build/test-results/**/*.xml',
|
|
407
|
+
report_file: 'build/test-results', dependency: nil, fp_run: false)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# --- Shared -----------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
def runner(name, dir, command:, report_command:, report_file:, dependency:, fp_run:, alternative: nil)
|
|
413
|
+
cd = dir == '.' ? '' : "cd #{dir} && "
|
|
414
|
+
{
|
|
415
|
+
'name' => name,
|
|
416
|
+
'dir' => dir,
|
|
417
|
+
'command' => command,
|
|
418
|
+
'report_command' => "#{cd}#{report_command}",
|
|
419
|
+
'report_file' => dir == '.' ? report_file : File.join(dir, report_file),
|
|
420
|
+
'dependency' => dependency,
|
|
421
|
+
'alternative' => alternative,
|
|
422
|
+
# `fp run` injects the formatter itself, so it needs no dependency at all.
|
|
423
|
+
'fp_run' => fp_run ? "#{cd}fp run --surface <surface> --repo <org/repo> -- #{command}" : nil
|
|
424
|
+
}.compact
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def surface(key, dir, reason, note = nil)
|
|
428
|
+
{ 'key' => key, 'dir' => dir, 'reasons' => [reason], 'note' => note }.compact
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def api_surface(dir, reason)
|
|
432
|
+
surface('api', dir, reason,
|
|
433
|
+
'Only a surface if customers call it directly. If it only serves your own ' \
|
|
434
|
+
'web or mobile app, report its tests against that surface instead.')
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def merge_surfaces(surfaces)
|
|
438
|
+
surfaces.group_by { |s| s['key'] }.map do |key, group|
|
|
439
|
+
merged = { 'key' => key, 'reasons' => group.flat_map { |s| s['reasons'] }.uniq }
|
|
440
|
+
note = group.map { |s| s['note'] }.compact.first
|
|
441
|
+
merged['note'] = note if note
|
|
442
|
+
merged
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def compose_databases
|
|
447
|
+
COMPOSE_FILES.flat_map do |name|
|
|
448
|
+
file = File.join(root, name)
|
|
449
|
+
next [] unless File.file?(file)
|
|
450
|
+
|
|
451
|
+
File.read(file).scan(/^\s*image:\s*["']?(?:[\w.-]+\/)*([a-z0-9_-]+)/).flatten.uniq.filter_map do |image|
|
|
452
|
+
kind = COMPOSE_IMAGES[image]
|
|
453
|
+
{ 'kind' => kind, 'evidence' => "#{name}: #{image} service" } if kind
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
|
|
458
|
+
def database_summary(databases)
|
|
459
|
+
databases = databases.uniq
|
|
460
|
+
kinds = databases.map { |d| d['kind'] }.uniq
|
|
461
|
+
server = kinds & SERVER_DATABASES
|
|
462
|
+
{
|
|
463
|
+
'needed' => !server.empty?,
|
|
464
|
+
'kinds' => kinds,
|
|
465
|
+
'evidence' => databases.map { |d| d['evidence'] }.uniq
|
|
466
|
+
}
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def devcontainer_path
|
|
470
|
+
%w[.devcontainer/devcontainer.json .devcontainer.json .devcontainer].find do |candidate|
|
|
471
|
+
File.exist?(File.join(root, candidate))
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def git_remote_url
|
|
476
|
+
return @git_remote_url if defined?(@git_remote_url)
|
|
477
|
+
|
|
478
|
+
@git_remote_url = begin
|
|
479
|
+
out, status = Open3.capture2e('git', '-C', root, 'remote', 'get-url', 'origin')
|
|
480
|
+
status.success? ? out.strip : nil
|
|
481
|
+
rescue SystemCallError
|
|
482
|
+
nil
|
|
483
|
+
end
|
|
484
|
+
@git_remote_url = nil if @git_remote_url&.empty?
|
|
485
|
+
@git_remote_url
|
|
486
|
+
end
|
|
487
|
+
end
|
|
488
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fp
|
|
4
|
+
# Local wording checks for a requirement before it is written (Fizzy #1786).
|
|
5
|
+
#
|
|
6
|
+
# `fp analyze` reviews a requirement already in the catalog; an agent writing a batch
|
|
7
|
+
# of drafts wants the cheap mechanical checks *before* anything is created, so a human
|
|
8
|
+
# can review the batch with `fp propose --dry-run` and nothing lands half-finished.
|
|
9
|
+
# These are the rules from the requirement-writing guidance that a string can be
|
|
10
|
+
# tested against: a promise for a title, no filler, a stated outcome, one rule, an
|
|
11
|
+
# example, and no numbers nobody decided. Everything here is a warning — the human
|
|
12
|
+
# product owner decides scope, and a heuristic doesn't get to veto a draft.
|
|
13
|
+
module RequirementLint
|
|
14
|
+
# Titles that name the work rather than the promise.
|
|
15
|
+
WORK_VERBS = %w[implement add build create refactor fix support enable integrate migrate].freeze
|
|
16
|
+
|
|
17
|
+
# Commentary about the requirement instead of the requirement.
|
|
18
|
+
COMMENTARY_OPENINGS = [
|
|
19
|
+
'this feature', 'this requirement', 'the goal is', 'users should be able to',
|
|
20
|
+
'the system should', 'we need to', 'we should', 'as a user'
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# Phrases that sell instead of saying anything a reviewer can check.
|
|
24
|
+
FILLER = [
|
|
25
|
+
'seamless', 'robust', 'user-friendly', 'intuitive', 'best-in-class', 'world-class',
|
|
26
|
+
'leverage', 'streamline', 'cutting-edge', 'state-of-the-art', 'ensure a smooth',
|
|
27
|
+
'enhance the user experience', 'improve the user experience', 'delightful'
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
Finding = Struct.new(:field, :message, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
# spec: a hash with :slug, :title, :why, :acceptance, :source_type (string keys or
|
|
35
|
+
# symbol keys). Returns [Finding, ...] — empty when nothing stood out.
|
|
36
|
+
def check(spec)
|
|
37
|
+
get = ->(k) { (spec[k] || spec[k.to_s]).to_s.strip }
|
|
38
|
+
title = get.call(:title)
|
|
39
|
+
why = get.call(:why)
|
|
40
|
+
acceptance = get.call(:acceptance)
|
|
41
|
+
findings = []
|
|
42
|
+
|
|
43
|
+
first_word = title.split(/\s+/).first.to_s.downcase
|
|
44
|
+
if WORK_VERBS.include?(first_word)
|
|
45
|
+
findings << Finding.new(field: 'title',
|
|
46
|
+
message: "starts with '#{first_word}' — name the promise a user gets, not the work " \
|
|
47
|
+
'(e.g. "Dispatchers see current shipment status", not "Implement status polling")')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
{ 'title' => title, 'why' => why, 'acceptance' => acceptance }.each do |field, text|
|
|
51
|
+
opening = COMMENTARY_OPENINGS.find { |o| text.downcase.start_with?(o) }
|
|
52
|
+
if opening
|
|
53
|
+
findings << Finding.new(field: field,
|
|
54
|
+
message: "opens with \"#{opening}\" — state the promise itself, with the real actor as the subject")
|
|
55
|
+
end
|
|
56
|
+
filler = FILLER.select { |f| text.downcase.include?(f) }
|
|
57
|
+
unless filler.empty?
|
|
58
|
+
findings << Finding.new(field: field,
|
|
59
|
+
message: "uses filler (#{filler.join(', ')}) — say what a reviewer could observe instead")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
findings << Finding.new(field: 'why', message: 'is missing — who needs this, and what goes wrong without it?') if why.empty?
|
|
64
|
+
|
|
65
|
+
if acceptance.empty?
|
|
66
|
+
findings << Finding.new(field: 'acceptance', message: 'is missing — a reviewer needs a rule they can check')
|
|
67
|
+
else
|
|
68
|
+
rule, example = split_example(acceptance)
|
|
69
|
+
if example.nil?
|
|
70
|
+
findings << Finding.new(field: 'acceptance',
|
|
71
|
+
message: "has no 'Example: ...' — add one concrete case that would pass the rule")
|
|
72
|
+
end
|
|
73
|
+
sentences = rule.split(/(?<=[.!?])\s+(?=[A-Z])/).reject { |s| s.strip.empty? }
|
|
74
|
+
if sentences.length > 2
|
|
75
|
+
findings << Finding.new(field: 'acceptance',
|
|
76
|
+
message: "states #{sentences.length} sentences of rule — if they could pass or fail " \
|
|
77
|
+
'separately, split them into their own requirements')
|
|
78
|
+
end
|
|
79
|
+
if rule.match?(/\d/)
|
|
80
|
+
findings << Finding.new(field: 'acceptance',
|
|
81
|
+
message: 'contains a number — keep it only if the source decided it; never invent a limit, price or timeout')
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
if title.downcase == why.downcase && !why.empty?
|
|
86
|
+
findings << Finding.new(field: 'why', message: 'repeats the title — say why the outcome matters')
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if get.call(:source_type).empty?
|
|
90
|
+
findings << Finding.new(field: 'source',
|
|
91
|
+
message: 'none given — pass --source-type/--source-url (repo_scan with --source-ref when you read it from the code)')
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
findings
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# [rule, example_or_nil] — the example is whatever follows "Example:".
|
|
98
|
+
def split_example(acceptance)
|
|
99
|
+
idx = acceptance =~ /\bexample:/i
|
|
100
|
+
return [acceptance, nil] if idx.nil?
|
|
101
|
+
|
|
102
|
+
[acceptance[0...idx].strip, acceptance[idx..].sub(/\Aexample:\s*/i, '').strip]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Fp
|
|
6
|
+
module JUnit
|
|
7
|
+
# RSpec's built-in JSON report (`rspec --format json --out rspec.json`), read into the
|
|
8
|
+
# same TestCase structs a JUnit report produces (Fizzy #1786). Getting JUnit out of
|
|
9
|
+
# RSpec takes the `rspec_junit_formatter` gem, which most Gemfiles don't carry, so a
|
|
10
|
+
# first `fp report` meant editing the project before it could report anything. The
|
|
11
|
+
# JSON formatter ships with rspec-core and carries everything binding needs: the file,
|
|
12
|
+
# the example's line, its full description and its status.
|
|
13
|
+
module RspecJson
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Content sniffing rather than a flag or a file extension: every `--junit` option
|
|
17
|
+
# accepts it unchanged, and an XML report can never start with `{`.
|
|
18
|
+
def json?(content)
|
|
19
|
+
content.lstrip.start_with?('{')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def parse(content, path = 'report')
|
|
23
|
+
data = JSON.parse(content)
|
|
24
|
+
examples = data.is_a?(Hash) ? data['examples'] : nil
|
|
25
|
+
unless examples.is_a?(Array)
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"#{path} is JSON but not RSpec's --format json output (no \"examples\" list). " \
|
|
28
|
+
'Pass a JUnit XML report or `rspec --format json --out <file>`.'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
examples.map { |example| testcase(example) }
|
|
32
|
+
rescue JSON::ParserError => e
|
|
33
|
+
raise ArgumentError, "Could not parse JSON report (#{path}): #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# passed → present; pending (RSpec's `skip`/`pending`/`xit`) → skipped, which
|
|
37
|
+
# agents file as a stub, as with a JUnit <skipped/>; failed → failed, which only
|
|
38
|
+
# CI acts on.
|
|
39
|
+
def testcase(example)
|
|
40
|
+
file = example['file_path'].to_s
|
|
41
|
+
status = example['status'].to_s
|
|
42
|
+
TestCase.new(
|
|
43
|
+
name: (example['full_description'] || example['description']).to_s,
|
|
44
|
+
classname: file,
|
|
45
|
+
file: file.empty? ? nil : file,
|
|
46
|
+
line: example['line_number']&.to_i,
|
|
47
|
+
status: status == 'pending' ? :skipped : :present,
|
|
48
|
+
failed: status == 'failed',
|
|
49
|
+
attachments: []
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/fp/version.rb
CHANGED
data/lib/fp.rb
CHANGED