From 5fa9dce5885b86ef7fdb52df3e7d16f5611abaae Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 1 Oct 2026 08:15:34 +0100 Subject: [PATCH] Detect RubyGems projects and alternate Bundler manifests --- README.md | 2 +- cmd/brief/ruby_test.go | 61 ++++++++++++++ detect/bundler.go | 53 +++++++++++++ detect/detect.go | 9 +-- detect/ruby_package_manager_test.go | 118 ++++++++++++++++++++++++++++ knowledge/_shared/_manifests.toml | 2 + knowledge/ruby/bundler.toml | 6 +- knowledge/ruby/language.toml | 2 +- knowledge/ruby/rubygems.toml | 24 ++++++ 9 files changed, 267 insertions(+), 10 deletions(-) create mode 100644 cmd/brief/ruby_test.go create mode 100644 detect/bundler.go create mode 100644 detect/ruby_package_manager_test.go create mode 100644 knowledge/ruby/rubygems.toml diff --git a/README.md b/README.md index 178b7d3..fbef69c 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ Language ecosystems and development tools across multiple categories. **Languages:** Ada, C, C#, C++, COBOL, Clojure, Common Lisp, Crystal, D, Dart, Deno, Elixir, Elm, Emacs Lisp, Erlang, F#, Fortran, GDScript, Gleam, Go, Groovy, Haskell, Haxe, Java, JavaScript, Julia, Kotlin, Lua, Mojo, Nim, Nix, OCaml, Objective-C, Odin, PHP, Perl, Prolog, Python, R, Racket, Roc, Ruby, Rust, Scala, Scheme, Solidity, Swift, Tcl, TypeScript, V, VHDL, Verilog, Zig. -**Package Managers:** Alire, Bun, Bundler, Cabal, Cargo, CocoaPods, Composer, Conan, Conda, DUB, Deno Modules, Flit, Gleam Packages, Go Modules, Gradle, Hatch, Haxelib, Jsonnet Bundler, Maven, Mint, Mix, Nix Flakes, NuGet, PDM, Pipenv, Pkg, Poetry, Quicklisp, Shards, Stack, Swift Package Manager, Yarn, cpanm, dotnet CLI, elm, fpm, npm, opam, pip, pnpm, pub, rebar3, renv, sbt, setuptools, uv, vcpkg. +**Package Managers:** Alire, Bun, Bundler, Cabal, Cargo, CocoaPods, Composer, Conan, Conda, DUB, Deno Modules, Flit, Gleam Packages, Go Modules, Gradle, Hatch, Haxelib, Jsonnet Bundler, Maven, Mint, Mix, Nix Flakes, NuGet, PDM, Pipenv, Pkg, Poetry, Quicklisp, RubyGems, Shards, Stack, Swift Package Manager, Yarn, cpanm, dotnet CLI, elm, fpm, npm, opam, pip, pnpm, pub, rebar3, renv, sbt, setuptools, uv, vcpkg. **Test:** ASV, AVA, Alcotest, Artillery, Bats, BenchmarkTools.jl, Bruno, Catch2, Cucumber, Cypress, EUnit, ExUnit, Gatling, Ginkgo, Google Test, Hspec, Hurl, JUnit, Jasmine, Jest, Kotest, Lighthouse CI, Locust, MSW, Minitest, Mocha, Newman, PHPUnit, Pest, Playwright, REST Client, RSpec, ScalaTest, Selenium, Testify, Testing Library, Vitest, XCTest, axe-core, benchmark-ips, cargo test, clojure.test, criterion, crystal spec, dart test, deno test, dotnet test, gleam test, go test, hyperfine, k6, kotlin.test, nf-test, pytest, pytest-benchmark, tape, testament, testthat, tinytest, tox, vdiffr, zig test. diff --git a/cmd/brief/ruby_test.go b/cmd/brief/ruby_test.go new file mode 100644 index 0000000..d3ab237 --- /dev/null +++ b/cmd/brief/ruby_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/git-pkgs/brief" +) + +func TestScanRubyPackageManagers(t *testing.T) { + const helperEnv = "BRIEF_RUBY_PACKAGE_MANAGER_ROOT" + if root := os.Getenv(helperEnv); root != "" { + cmdScan([]string{"-json", root}) + return + } + for _, tt := range []struct { + name string + path string + files map[string]string + manager string + lockfile string + }{ + {"gem subdirectory", "packages/parser", map[string]string{ + "Gemfile": "source 'https://rubygems.org'\ngem 'rubocop'\n", + "packages/parser/parser.gemspec": "Gem::Specification.new do |spec|\n spec.name = 'parser'\nend\n", + }, "RubyGems", ""}, + {"alternate Bundler layout", ".", map[string]string{ + "gems.rb": "source 'https://rubygems.org'\ngem 'rake'\n", + "gems.locked": "GEM\n specs:\n rake (13.0.0)\n\nDEPENDENCIES\n rake\n", + }, "Bundler", "gems.locked"}, + {"Hoe project", ".", map[string]string{ + "Rakefile": "require 'hoe'\nHoe.spec 'parser' do\nend\n", + }, "RubyGems", ""}, + } { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + for path, content := range tt.files { + writeScanFixture(t, root, path, content) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestScanRubyPackageManagers$") + cmd.Env = append(os.Environ(), helperEnv+"="+filepath.Join(root, tt.path), "PATH=") + out, err := cmd.Output() + if err != nil { + t.Fatalf("scan failed: %v", err) + } + var report brief.Report + if err := json.Unmarshal(out, &report); err != nil { + t.Fatalf("parsing scan output: %v\n%s", err, out) + } + if len(report.PackageManagers) != 1 || report.PackageManagers[0].Name != tt.manager { + t.Fatalf("package managers = %+v, want %s", report.PackageManagers, tt.manager) + } + if got := report.PackageManagers[0].Lockfile; got != tt.lockfile { + t.Errorf("lockfile = %q, want %q", got, tt.lockfile) + } + }) + } +} diff --git a/detect/bundler.go b/detect/bundler.go new file mode 100644 index 0000000..709f7b5 --- /dev/null +++ b/detect/bundler.go @@ -0,0 +1,53 @@ +package detect + +import ( + "path" + "path/filepath" + "slices" + + "github.com/git-pkgs/brief/kb" +) + +const ( + bundlerGemfile = "Gemfile" + bundlerGemfileLock = "Gemfile.lock" + bundlerGemsRB = "gems.rb" + bundlerGemsLocked = "gems.locked" +) + +func (e *Engine) bundlerFiles(root string) (manifest, lockfile string) { + root = filepath.ToSlash(root) + if e.exactFileExists(path.Join(root, bundlerGemsRB)) { + return path.Join(root, bundlerGemsRB), path.Join(root, bundlerGemsLocked) + } + return path.Join(root, bundlerGemfile), path.Join(root, bundlerGemfileLock) +} + +func (e *Engine) activeBundlerFile(root, file string) bool { + switch file { + case bundlerGemfile, bundlerGemfileLock, bundlerGemsRB, bundlerGemsLocked: + manifest, lockfile := e.bundlerFiles(root) + candidate := path.Join(filepath.ToSlash(root), file) + return candidate == manifest || candidate == lockfile + default: + return true + } +} + +func (e *Engine) detectLockfile(tool *kb.ToolDef) string { + if slices.Contains(tool.Config.Files, bundlerGemsRB) { + for _, root := range e.analysisRoots() { + _, lockfile := e.bundlerFiles(root) + if e.exactFileExists(lockfile) { + return lockfile + } + } + return "" + } + if tool.Config.Lockfile != "" { + if found := e.findExisting([]string{tool.Config.Lockfile}); len(found) > 0 { + return found[0] + } + } + return "" +} diff --git a/detect/detect.go b/detect/detect.go index 8392688..450acf4 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -554,11 +554,7 @@ func (e *Engine) detectCategory(category string) []brief.Detection { d.ConfigFiles = e.findExisting(tool.Config.Files) - if tool.Config.Lockfile != "" { - if lockfiles := e.findExisting([]string{tool.Config.Lockfile}); len(lockfiles) > 0 { - d.Lockfile = lockfiles[0] - } - } + d.Lockfile = e.detectLockfile(tool) detections = append(detections, d) } @@ -1136,6 +1132,9 @@ func (e *Engine) manifestPaths() []string { roots := e.analysisRoots() for _, root := range roots { for _, mf := range e.KB.ManifestFiles { + if !e.activeBundlerFile(root, mf) { + continue + } add(path.Join(filepath.ToSlash(root), mf)) } } diff --git a/detect/ruby_package_manager_test.go b/detect/ruby_package_manager_test.go new file mode 100644 index 0000000..88d8121 --- /dev/null +++ b/detect/ruby_package_manager_test.go @@ -0,0 +1,118 @@ +package detect + +import ( + "slices" + "testing" + + "github.com/git-pkgs/brief" +) + +func TestRubyGemsPackageManager(t *testing.T) { + t.Setenv("PATH", "") + for name, files := range map[string]map[string]string{ + "gemspec only": {"parser.gemspec": "Gem::Specification.new do |spec|\n spec.name = 'parser'\nend\n"}, + "nested gemspec": {"packages/parser/parser.gemspec": "Gem::Specification.new do |spec|\n spec.name = 'parser'\nend\n"}, + "Hoe": {"Rakefile": "require 'hoe'\nHoe.spec 'parser' do\nend\n"}, + "lowercase Rakefile": {"rakefile": "require 'hoe'\nHoe.spec 'parser' do\nend\n"}, + "legacy Hoe": {"Rakefile": "require 'hoe'\nHoe.new 'parser', '0.1.0'\n"}, + "package task": {"Rakefile": "require 'rubygems/package_task'\nGem::PackageTask.new(spec)\n"}, + "plain Ruby": {"main.rb": "puts 'hello'\n"}, + "ordinary Rakefile": {"Rakefile": "task :test do\n ruby 'test.rb'\nend\n"}, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + for path, content := range files { + writeProjectFile(t, dir, path, content) + } + r := runOn(t, dir) + if !slices.Contains(languageNames(r), "Ruby") { + t.Fatalf("languages = %v, want Ruby", languageNames(r)) + } + want := name != "plain Ruby" && name != "ordinary Rakefile" + if got := slices.Contains(packageManagerNames(r), "RubyGems"); got != want { + t.Errorf("package managers = %v, want RubyGems presence %v", packageManagerNames(r), want) + } + if slices.Contains(packageManagerNames(r), "Bundler") { + t.Error("detected Bundler without a Bundler manifest") + } + }) + } +} + +func TestBundlerManifestLayouts(t *testing.T) { + t.Setenv("PATH", "") + const gemfile = "source 'https://rubygems.org'\ngem 'rubocop'\n" + const gemsRB = "source 'https://rubygems.org'\ngem 'rake'\n" + const oldLock = "GEM\n remote: https://rubygems.org/\n specs:\n rubocop (1.0.0)\n\nDEPENDENCIES\n rubocop\n" + const newLock = "GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.0)\n\nDEPENDENCIES\n rake\n" + for _, tt := range []struct { + name string + files map[string]string + manifest string + lockfile string + dependency string + }{ + {"Gemfile", map[string]string{"Gemfile": gemfile, "Gemfile.lock": oldLock}, "Gemfile", "Gemfile.lock", "rubocop"}, + {"gems.rb", map[string]string{"gems.rb": gemsRB, "gems.locked": newLock}, "gems.rb", "gems.locked", "rake"}, + {"both layouts", map[string]string{"Gemfile": gemfile, "Gemfile.lock": oldLock, "gems.rb": gemsRB, "gems.locked": newLock}, "gems.rb", "gems.locked", "rake"}, + {"missing alternate lockfile", map[string]string{"Gemfile": gemfile, "Gemfile.lock": oldLock, "gems.rb": gemsRB}, "gems.rb", "", "rake"}, + {"orphan alternate lockfile", map[string]string{"Gemfile": gemfile, "gems.locked": newLock}, "Gemfile", "", "rubocop"}, + } { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + for path, content := range tt.files { + writeProjectFile(t, dir, path, content) + } + r := runOn(t, dir) + if len(r.PackageManagers) != 1 || r.PackageManagers[0].Name != "Bundler" { + t.Fatalf("package managers = %v, want Bundler", packageManagerNames(r)) + } + bundler := r.PackageManagers[0] + if bundler.Lockfile != tt.lockfile { + t.Errorf("lockfile = %q, want %q", bundler.Lockfile, tt.lockfile) + } + for _, path := range []string{tt.manifest, tt.lockfile} { + if path != "" && !slices.Contains(bundler.ConfigFiles, path) { + t.Errorf("config files = %v, want %s", bundler.ConfigFiles, path) + } + } + checkBundlerManifests(t, r, tt.manifest, tt.lockfile, tt.dependency) + }) + } +} + +func checkBundlerManifests(t *testing.T, r *brief.Report, manifest, lockfile, dependency string) { + t.Helper() + want := []string{manifest} + if lockfile != "" { + want = append(want, lockfile) + } + var got []string + for _, m := range r.Manifests { + got = append(got, m.Path) + } + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("manifests = %v, want %v", got, want) + } + if len(r.Dependencies) == 0 { + t.Fatal("expected parsed dependencies") + } + for _, dep := range r.Dependencies { + if dep.Name != dependency { + t.Errorf("dependency = %s, want only %s", dep.Name, dependency) + } + } +} + +func TestRubyGemsAndBundler(t *testing.T) { + t.Setenv("PATH", "") + dir := t.TempDir() + writeProjectFile(t, dir, "Gemfile", "source 'https://rubygems.org'\ngemspec\n") + writeProjectFile(t, dir, "parser.gemspec", "Gem::Specification.new do |spec|\n spec.name = 'parser'\nend\n") + got := packageManagerNames(runOn(t, dir)) + if len(got) != 2 || !slices.Contains(got, "Bundler") || !slices.Contains(got, "RubyGems") { + t.Errorf("package managers = %v, want Bundler and RubyGems", got) + } +} diff --git a/knowledge/_shared/_manifests.toml b/knowledge/_shared/_manifests.toml index f9be0f0..88264c7 100644 --- a/knowledge/_shared/_manifests.toml +++ b/knowledge/_shared/_manifests.toml @@ -3,6 +3,8 @@ files = [ ".gitmodules", "Gemfile", "Gemfile.lock", + "gems.rb", + "gems.locked", "package.json", "package-lock.json", "yarn.lock", diff --git a/knowledge/ruby/bundler.toml b/knowledge/ruby/bundler.toml index 461640d..c2b2cba 100644 --- a/knowledge/ruby/bundler.toml +++ b/knowledge/ruby/bundler.toml @@ -3,11 +3,11 @@ name = "Bundler" category = "package_manager" homepage = "https://bundler.io" docs = "https://bundler.io/docs.html" -repo = "https://github.com/rubygems/rubygems" +repo = "https://github.com/ruby/rubygems" description = "Ruby dependency manager" [detect] -files = ["Gemfile"] +files = ["Gemfile", "gems.rb"] ecosystems = ["ruby"] [commands] @@ -15,7 +15,7 @@ run = "bundle install" alternatives = ["bundle"] [config] -files = ["Gemfile", "Gemfile.lock", ".bundle/config"] +files = ["Gemfile", "Gemfile.lock", "gems.rb", "gems.locked", ".bundle/config"] lockfile = "Gemfile.lock" [taxonomy] diff --git a/knowledge/ruby/language.toml b/knowledge/ruby/language.toml index b9eb9b8..b32fbe1 100644 --- a/knowledge/ruby/language.toml +++ b/knowledge/ruby/language.toml @@ -7,7 +7,7 @@ repo = "https://github.com/ruby/ruby" description = "Dynamic, object-oriented programming language" [detect] -files = ["Gemfile", "Rakefile", "*.rb", "**/*.rb", "*.gemspec"] +files = ["Gemfile", "gems.rb", "Rakefile", "rakefile", "*.rb", "**/*.rb", "*.gemspec", "**/*.gemspec"] ecosystems = ["ruby"] [taxonomy] diff --git a/knowledge/ruby/rubygems.toml b/knowledge/ruby/rubygems.toml new file mode 100644 index 0000000..24bcaa7 --- /dev/null +++ b/knowledge/ruby/rubygems.toml @@ -0,0 +1,24 @@ +[tool] +name = "RubyGems" +category = "package_manager" +homepage = "https://rubygems.org" +docs = "https://guides.rubygems.org/command-reference/" +repo = "https://github.com/ruby/rubygems" +description = "Ruby package manager" + +[detect] +files = ["*.gemspec", "**/*.gemspec"] +dependencies = ["hoe"] +ecosystems = ["ruby"] + +[detect.file_contains] +"Rakefile" = ["Hoe.spec", "Hoe.new", "Gem::PackageTask"] +"rakefile" = ["Hoe.spec", "Hoe.new", "Gem::PackageTask"] +"Rakefile.rb" = ["Hoe.spec", "Hoe.new", "Gem::PackageTask"] +"rakefile.rb" = ["Hoe.spec", "Hoe.new", "Gem::PackageTask"] + +[config] +files = ["*.gemspec", "**/*.gemspec", ".gemrc"] + +[taxonomy] +role = ["package-manager"]