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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fp/cli.rb +14 -2
  3. data/lib/fp/client.rb +79 -2
  4. data/lib/fp/commands/analyze.rb +17 -0
  5. data/lib/fp/commands/base.rb +16 -0
  6. data/lib/fp/commands/check.rb +38 -4
  7. data/lib/fp/commands/ci_report.rb +2 -2
  8. data/lib/fp/commands/comment.rb +1 -1
  9. data/lib/fp/commands/comments.rb +146 -0
  10. data/lib/fp/commands/coverage.rb +149 -0
  11. data/lib/fp/commands/doctor.rb +228 -0
  12. data/lib/fp/commands/help.rb +41 -1
  13. data/lib/fp/commands/history.rb +16 -1
  14. data/lib/fp/commands/init.rb +181 -117
  15. data/lib/fp/commands/list.rb +58 -4
  16. data/lib/fp/commands/mark.rb +167 -0
  17. data/lib/fp/commands/projects.rb +6 -1
  18. data/lib/fp/commands/propose.rb +435 -53
  19. data/lib/fp/commands/propose_change.rb +13 -7
  20. data/lib/fp/commands/question.rb +137 -0
  21. data/lib/fp/commands/report.rb +40 -4
  22. data/lib/fp/commands/run.rb +147 -0
  23. data/lib/fp/commands/show.rb +41 -3
  24. data/lib/fp/commands/spot_check.rb +458 -0
  25. data/lib/fp/commands/tutorial.rb +95 -0
  26. data/lib/fp/commands/whoami.rb +2 -0
  27. data/lib/fp/commands.rb +8 -0
  28. data/lib/fp/config.rb +12 -0
  29. data/lib/fp/junit.rb +74 -11
  30. data/lib/fp/repo_detector.rb +488 -0
  31. data/lib/fp/requirement_lint.rb +105 -0
  32. data/lib/fp/rspec_json.rb +54 -0
  33. data/lib/fp/tutorial/hexhaven/AGENTS.md +5 -0
  34. data/lib/fp/tutorial/hexhaven/README.md +29 -0
  35. data/lib/fp/tutorial/hexhaven/lib/hexhaven.rb +36 -0
  36. data/lib/fp/tutorial/hexhaven/test/junit_reporter.rb +134 -0
  37. data/lib/fp/tutorial/hexhaven/test/rules_test.rb +36 -0
  38. data/lib/fp/tutorial/hexhaven/test/test_helper.rb +6 -0
  39. data/lib/fp/version.rb +1 -1
  40. data/lib/fp.rb +1 -0
  41. data/skills/fp/SKILL.md +244 -105
  42. data/skills/fp/references/cli.md +304 -3
  43. 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 JUnit XML file into an array of TestCase structs.
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
- xml = File.read(path)
129
- parse_string(xml)
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.attributes['line'] && el.attributes['line'].to_i),
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
- return if existing && !(existing.state == 'stub' && state == 'present')
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*(['"`])(.+?)\1/, :test], # vitest/jest/mocha
761
- [/\bdescribe(?:\.\w+)*\s*\(\s*(['"`])(.+?)\1/, :group], # vitest/jest/mocha group
762
- [/\b(?:it|specify|scenario)\s+(['"])(.+?)\1/, :test], # rspec
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