featureparity 0.0.27 → 0.0.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/fp/cli.rb +14 -2
- data/lib/fp/client.rb +79 -2
- data/lib/fp/commands/analyze.rb +17 -0
- data/lib/fp/commands/base.rb +16 -0
- data/lib/fp/commands/check.rb +38 -4
- data/lib/fp/commands/ci_report.rb +2 -2
- data/lib/fp/commands/comment.rb +1 -1
- data/lib/fp/commands/comments.rb +146 -0
- data/lib/fp/commands/coverage.rb +149 -0
- data/lib/fp/commands/doctor.rb +228 -0
- data/lib/fp/commands/help.rb +41 -1
- data/lib/fp/commands/history.rb +16 -1
- data/lib/fp/commands/init.rb +181 -117
- data/lib/fp/commands/list.rb +58 -4
- data/lib/fp/commands/mark.rb +167 -0
- data/lib/fp/commands/projects.rb +6 -1
- data/lib/fp/commands/propose.rb +435 -53
- data/lib/fp/commands/propose_change.rb +13 -7
- data/lib/fp/commands/question.rb +137 -0
- data/lib/fp/commands/report.rb +40 -4
- data/lib/fp/commands/run.rb +147 -0
- data/lib/fp/commands/show.rb +41 -3
- data/lib/fp/commands/spot_check.rb +458 -0
- data/lib/fp/commands/tutorial.rb +95 -0
- data/lib/fp/commands/whoami.rb +2 -0
- data/lib/fp/commands.rb +8 -0
- data/lib/fp/config.rb +12 -0
- data/lib/fp/junit.rb +74 -11
- data/lib/fp/repo_detector.rb +488 -0
- data/lib/fp/requirement_lint.rb +105 -0
- data/lib/fp/rspec_json.rb +54 -0
- data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
- data/lib/fp/tutorial/hexhaven/README.md +29 -0
- data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
- data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
- data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
- data/lib/fp/version.rb +1 -1
- data/lib/fp.rb +1 -0
- data/skills/fp/SKILL.md +244 -105
- data/skills/fp/references/cli.md +304 -3
- metadata +19 -2
data/lib/fp/junit.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require 'rexml/document'
|
|
4
4
|
require 'pathname'
|
|
5
5
|
require_relative 'source_fingerprint'
|
|
6
|
+
require_relative 'rspec_json'
|
|
6
7
|
|
|
7
8
|
module Fp
|
|
8
9
|
# Parses JUnit XML reports and binds testcases to fp:<slug> markers found in
|
|
@@ -83,7 +84,7 @@ module Fp
|
|
|
83
84
|
# testcase's own line when the runner reported one, otherwise the line of
|
|
84
85
|
# the fp:<slug> marker that annotates it (markers sit directly above the
|
|
85
86
|
# test, so the marker line is a close stand-in when the runner omits lines).
|
|
86
|
-
Binding = Struct.new(:slug, :surface, :test_type, :file, :line, :title, :state, keyword_init: true)
|
|
87
|
+
Binding = Struct.new(:slug, :surface, :test_type, :file, :line, :title, :state, :tests, keyword_init: true)
|
|
87
88
|
|
|
88
89
|
# A media file (video/screenshot) a run recorded for one bound testcase, ready for
|
|
89
90
|
# `fp report --junit --attach-media` to upload as visual evidence. Unlike Binding,
|
|
@@ -120,17 +121,51 @@ module Fp
|
|
|
120
121
|
|
|
121
122
|
module_function
|
|
122
123
|
|
|
123
|
-
# Parse a
|
|
124
|
+
# Parse a test report into an array of TestCase structs: JUnit XML, or RSpec's
|
|
125
|
+
# built-in `--format json` output (Fizzy #1786), told apart by content so every
|
|
126
|
+
# `--junit` flag takes either with no new option. RSpec's JSON needs no extra gem,
|
|
127
|
+
# where JUnit from RSpec needs `rspec_junit_formatter` in the project's Gemfile.
|
|
124
128
|
# Raises ArgumentError if the file is missing or unparseable.
|
|
125
129
|
def parse_file(path)
|
|
126
130
|
raise ArgumentError, "JUnit file not found: #{path}" unless File.file?(path)
|
|
127
131
|
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
content = File.read(path)
|
|
133
|
+
return RspecJson.parse(content, path) if RspecJson.json?(content)
|
|
134
|
+
|
|
135
|
+
parse_string(content)
|
|
130
136
|
rescue REXML::ParseException => e
|
|
131
137
|
raise ArgumentError, "Could not parse JUnit XML (#{path}): #{e.message}"
|
|
132
138
|
end
|
|
133
139
|
|
|
140
|
+
# The report as JUnit XML, whatever format the file is in. The CLI resolves markers
|
|
141
|
+
# itself, but ci-report and check also send the raw report for servers old enough to
|
|
142
|
+
# re-resolve it, and those only read XML.
|
|
143
|
+
def read_xml(path)
|
|
144
|
+
content = File.read(path)
|
|
145
|
+
return content unless RspecJson.json?(content)
|
|
146
|
+
|
|
147
|
+
to_xml(RspecJson.parse(content, path))
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Render parsed testcases as a minimal JUnit document.
|
|
151
|
+
def to_xml(testcases)
|
|
152
|
+
doc = REXML::Document.new
|
|
153
|
+
doc << REXML::XMLDecl.new('1.0', 'UTF-8')
|
|
154
|
+
suite = doc.add_element('testsuites').add_element('testsuite', 'name' => 'fp',
|
|
155
|
+
'tests' => testcases.length.to_s)
|
|
156
|
+
testcases.each do |tc|
|
|
157
|
+
attrs = { 'name' => tc.name.to_s, 'classname' => tc.classname.to_s }
|
|
158
|
+
attrs['file'] = tc.file if tc.file
|
|
159
|
+
attrs['line'] = tc.line.to_s if tc.line
|
|
160
|
+
el = suite.add_element('testcase', attrs)
|
|
161
|
+
el.add_element('skipped') if tc.stub?
|
|
162
|
+
el.add_element('failure') if tc.failed?
|
|
163
|
+
end
|
|
164
|
+
out = +''
|
|
165
|
+
doc.write(out)
|
|
166
|
+
out
|
|
167
|
+
end
|
|
168
|
+
|
|
134
169
|
def parse_string(xml)
|
|
135
170
|
doc = REXML::Document.new(xml)
|
|
136
171
|
cases = []
|
|
@@ -144,7 +179,7 @@ module Fp
|
|
|
144
179
|
name: el.attributes['name'],
|
|
145
180
|
classname: classname,
|
|
146
181
|
file: resolve_file(el.attributes['file'], classname, suite_name),
|
|
147
|
-
line: (el
|
|
182
|
+
line: testcase_line(el, suite_name),
|
|
148
183
|
status: testcase_status(el),
|
|
149
184
|
failed: testcase_failed?(el),
|
|
150
185
|
attachments: extract_attachments(el)
|
|
@@ -154,6 +189,16 @@ module Fp
|
|
|
154
189
|
cases
|
|
155
190
|
end
|
|
156
191
|
|
|
192
|
+
# pytest's `line` (written with junit_family=xunit1, the only family that carries a
|
|
193
|
+
# file) counts from 0, so a `def` directly under its marker would report the marker's
|
|
194
|
+
# own line and bind to the marker above that. Its suites are named "pytest".
|
|
195
|
+
def testcase_line(el, suite_name)
|
|
196
|
+
line = el.attributes['line']&.to_i
|
|
197
|
+
return line unless line && suite_name == 'pytest' && el.attributes['file'].to_s.end_with?('.py')
|
|
198
|
+
|
|
199
|
+
line + 1
|
|
200
|
+
end
|
|
201
|
+
|
|
157
202
|
# Playwright's JUnit reporter records media (video, screenshots, trace) by writing
|
|
158
203
|
# `[[ATTACHMENT|<path>]]` lines into the testcase's `<system-out>`. Pull those paths
|
|
159
204
|
# out so `fp report --junit --attach-media` can upload them as visual evidence for the
|
|
@@ -600,12 +645,20 @@ module Fp
|
|
|
600
645
|
# omitted line info.
|
|
601
646
|
line = testcase.line || (marker && marker[:line])
|
|
602
647
|
|
|
648
|
+
# Every testcase that fed this receipt, so `fp report` can say "3 tests → one
|
|
649
|
+
# receipt" instead of printing one line and leaving the other two unaccounted for.
|
|
650
|
+
entry = { name: presence(testcase.name), line: line, state: state }
|
|
651
|
+
|
|
603
652
|
existing = bindings[key]
|
|
604
653
|
# Prefer a concrete 'present' state over 'stub' when a slug has both.
|
|
605
|
-
|
|
654
|
+
if existing && !(existing.state == 'stub' && state == 'present')
|
|
655
|
+
existing.tests << entry
|
|
656
|
+
return
|
|
657
|
+
end
|
|
606
658
|
|
|
607
659
|
bindings[key] = Binding.new(slug: slug, surface: surface, test_type: test_type, file: rel_file,
|
|
608
|
-
line: line, title: (testcase.name if titles_reliable), state: state
|
|
660
|
+
line: line, title: (testcase.name if titles_reliable), state: state,
|
|
661
|
+
tests: (existing ? existing.tests : []) + [entry])
|
|
609
662
|
end
|
|
610
663
|
|
|
611
664
|
# Load and cache the fp:<slug> markers (with line numbers) for an already-resolved
|
|
@@ -756,10 +809,16 @@ module Fp
|
|
|
756
809
|
# How a test (or group) declaration announces its name, per language. Each entry is
|
|
757
810
|
# [regex, kind]; the captured group is the name. `:group` entries are describe-style
|
|
758
811
|
# blocks, whose name prefixes the names of the tests inside them.
|
|
812
|
+
#
|
|
813
|
+
# A quoted name may contain its own quote escaped (`'its kind\'s logo'`), so the name
|
|
814
|
+
# runs to the first *unescaped* closing quote and is unescaped before matching — the
|
|
815
|
+
# runner reports `its kind's logo`. Stopping at the first quote cut the name off at
|
|
816
|
+
# `kind\` and the marker silently bound nothing (Fizzy #1759).
|
|
817
|
+
QUOTED_NAME = %q{((?:\\\\.|(?!\\1).)+?)\\1}
|
|
759
818
|
TEST_DECL_PATTERNS = [
|
|
760
|
-
[/\b(?:it|test|bench)(?:\.\w+)*\s*\(\s*(['"`])
|
|
761
|
-
[/\bdescribe(?:\.\w+)*\s*\(\s*(['"`])
|
|
762
|
-
[/\b(?:it|specify|scenario)\s+(['"])
|
|
819
|
+
[/\b(?:it|test|bench)(?:\.\w+)*\s*\(\s*(['"`])#{QUOTED_NAME}/, :test], # vitest/jest/mocha
|
|
820
|
+
[/\bdescribe(?:\.\w+)*\s*\(\s*(['"`])#{QUOTED_NAME}/, :group], # vitest/jest/mocha group
|
|
821
|
+
[/\b(?:it|specify|scenario)\s+(['"])#{QUOTED_NAME}/, :test], # rspec
|
|
763
822
|
[/\b(?:def|fun)\s+`(.+?)`/, :test], # kotlin backticked
|
|
764
823
|
[/\b(?:def|fun|func)\s+(test_?\w+)/i, :test] # minitest / xctest / pytest
|
|
765
824
|
].freeze
|
|
@@ -779,7 +838,7 @@ module Fp
|
|
|
779
838
|
next unless match
|
|
780
839
|
|
|
781
840
|
# Two-quote patterns capture the quote first, the name second.
|
|
782
|
-
return [match.captures.last, kind]
|
|
841
|
+
return [unescape_name(match.captures.last), kind]
|
|
783
842
|
end
|
|
784
843
|
|
|
785
844
|
# A line of real code that declares nothing recognisable: the marker doesn't
|
|
@@ -794,5 +853,9 @@ module Fp
|
|
|
794
853
|
# the first line of real code regardless, so this only bounds how long a comment block
|
|
795
854
|
# between the marker and its test may be.
|
|
796
855
|
DECL_SEARCH_WINDOW = 40
|
|
856
|
+
|
|
857
|
+
def unescape_name(name)
|
|
858
|
+
name.gsub(/\\(.)/, '\\1')
|
|
859
|
+
end
|
|
797
860
|
end
|
|
798
861
|
end
|
|
@@ -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
|