featureparity 0.0.28 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fp
4
- VERSION = '0.0.28'
4
+ VERSION = '0.0.29'
5
5
  end
data/lib/fp.rb CHANGED
@@ -7,6 +7,7 @@ require_relative 'fp/parity'
7
7
  require_relative 'fp/client'
8
8
  require_relative 'fp/source_fingerprint'
9
9
  require_relative 'fp/junit'
10
+ require_relative 'fp/repo_detector'
10
11
  require_relative 'fp/output'
11
12
  require_relative 'fp/commands'
12
13
  require_relative 'fp/cli'