This is an automated email from the ASF dual-hosted git repository.
kezhenxu94 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-eyes.git
The following commit(s) were added to refs/heads/main by this push:
new 13c0e5b fix(deps): Ruby: local path & recursive resolution of deps
(#255)
13c0e5b is described below
commit 13c0e5b2689ef8f93eb4b7990b04c72fc293bb4f
Author: |7eter l-|. l3oling <[email protected]>
AuthorDate: Sat Dec 20 00:03:19 2025 -0700
fix(deps): Ruby: local path & recursive resolution of deps (#255)
---
cmd/license-eye/main.go | 2 +-
commands/deps_check.go | 2 +-
commands/deps_resolve.go | 2 +-
commands/header_check.go | 2 +-
commands/header_fix.go | 2 +-
commands/root.go | 2 +-
pkg/config/config.go | 2 +-
pkg/deps/cargo.go | 2 +-
pkg/deps/cargo_test.go | 2 +-
pkg/deps/check.go | 2 +-
pkg/deps/gemspec_test.go | 215 ++++++++
pkg/deps/golang.go | 2 +-
pkg/deps/jar.go | 2 +-
pkg/deps/jar_test.go | 2 +-
pkg/deps/maven.go | 2 +-
pkg/deps/npm.go | 2 +-
pkg/deps/resolve.go | 1 +
pkg/deps/ruby.go | 549 ++++++++++++++++++---
pkg/deps/ruby_test.go | 198 +++++++-
pkg/deps/testdata/ruby/app/Gemfile.lock | 2 +-
pkg/deps/testdata/ruby/citrus/Gemfile.lock | 24 +
pkg/deps/testdata/ruby/citrus/citrus.gemspec | 53 ++
pkg/deps/testdata/ruby/library/Gemfile.lock | 2 +-
pkg/deps/testdata/ruby/local_dep/Gemfile.lock | 22 +
.../testdata/ruby/local_dep/citrus/citrus.gemspec | 25 +
.../testdata/ruby/toml-merge/toml-merge.gemspec | 118 +++++
pkg/header/check.go | 2 +-
pkg/header/config.go | 2 +-
pkg/header/fix.go | 2 +-
pkg/license/identifier.go | 2 +-
pkg/license/norm.go | 2 +-
{internal => pkg}/logger/log.go | 0
pkg/review/header.go | 2 +-
33 files changed, 1149 insertions(+), 102 deletions(-)
diff --git a/cmd/license-eye/main.go b/cmd/license-eye/main.go
index 1448f4e..69285a2 100644
--- a/cmd/license-eye/main.go
+++ b/cmd/license-eye/main.go
@@ -21,7 +21,7 @@ import (
"os"
"github.com/apache/skywalking-eyes/commands"
- "github.com/apache/skywalking-eyes/internal/logger"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
func main() {
diff --git a/commands/deps_check.go b/commands/deps_check.go
index 48992b0..7cb57b7 100644
--- a/commands/deps_check.go
+++ b/commands/deps_check.go
@@ -22,8 +22,8 @@ import (
"github.com/spf13/cobra"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/deps"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
var weakCompatible bool
diff --git a/commands/deps_resolve.go b/commands/deps_resolve.go
index 51322b1..6378b71 100644
--- a/commands/deps_resolve.go
+++ b/commands/deps_resolve.go
@@ -29,8 +29,8 @@ import (
"github.com/spf13/cobra"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/deps"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
var (
diff --git a/commands/header_check.go b/commands/header_check.go
index c1aefca..72bd758 100644
--- a/commands/header_check.go
+++ b/commands/header_check.go
@@ -21,8 +21,8 @@ import (
"fmt"
"os"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/header"
+ "github.com/apache/skywalking-eyes/pkg/logger"
"github.com/apache/skywalking-eyes/pkg/review"
"github.com/spf13/cobra"
diff --git a/commands/header_fix.go b/commands/header_fix.go
index ed5a7cb..221a3eb 100644
--- a/commands/header_fix.go
+++ b/commands/header_fix.go
@@ -23,8 +23,8 @@ import (
"github.com/spf13/cobra"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/header"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
var FixCommand = &cobra.Command{
diff --git a/commands/root.go b/commands/root.go
index 1371894..ca9d472 100644
--- a/commands/root.go
+++ b/commands/root.go
@@ -18,8 +18,8 @@
package commands
import (
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/config"
+ "github.com/apache/skywalking-eyes/pkg/logger"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 1d822df..ef66bf4 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -21,9 +21,9 @@ import (
"os"
"github.com/apache/skywalking-eyes/assets"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/deps"
"github.com/apache/skywalking-eyes/pkg/header"
+ "github.com/apache/skywalking-eyes/pkg/logger"
"gopkg.in/yaml.v3"
)
diff --git a/pkg/deps/cargo.go b/pkg/deps/cargo.go
index 70a2f03..6481278 100644
--- a/pkg/deps/cargo.go
+++ b/pkg/deps/cargo.go
@@ -26,8 +26,8 @@ import (
"sort"
"strings"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/license"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
type CargoMetadata struct {
diff --git a/pkg/deps/cargo_test.go b/pkg/deps/cargo_test.go
index 0a11517..123f0fa 100644
--- a/pkg/deps/cargo_test.go
+++ b/pkg/deps/cargo_test.go
@@ -23,8 +23,8 @@ import (
"path/filepath"
"testing"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/deps"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
const (
diff --git a/pkg/deps/check.go b/pkg/deps/check.go
index 27cac1e..bd8d3ec 100644
--- a/pkg/deps/check.go
+++ b/pkg/deps/check.go
@@ -27,7 +27,7 @@ import (
"gopkg.in/yaml.v3"
"github.com/apache/skywalking-eyes/assets"
- "github.com/apache/skywalking-eyes/internal/logger"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
type CompatibilityMatrix struct {
diff --git a/pkg/deps/gemspec_test.go b/pkg/deps/gemspec_test.go
new file mode 100644
index 0000000..29012e7
--- /dev/null
+++ b/pkg/deps/gemspec_test.go
@@ -0,0 +1,215 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package deps
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+const (
+ tomlRb = "toml-rb"
+ citrus = "citrus"
+ mit = "MIT"
+)
+
+func TestRubyGemspecResolver(t *testing.T) {
+ resolver := new(GemspecResolver)
+
+ t.Run("toml-merge case", func(t *testing.T) {
+ tmp := t.TempDir()
+ if err := copyRuby("testdata/ruby/toml-merge", tmp); err != nil
{
+ t.Fatal(err)
+ }
+ gemspec := filepath.Join(tmp, "toml-merge.gemspec")
+ if !resolver.CanResolve(gemspec) {
+ t.Fatalf("GemspecResolver cannot resolve %s", gemspec)
+ }
+ cfg := &ConfigDeps{Files: []string{gemspec}}
+ report := Report{}
+ if err := resolver.Resolve(gemspec, cfg, &report); err != nil {
+ t.Fatal(err)
+ }
+
+ // Expect toml-rb dependency.
+ found := false
+ for _, r := range report.Resolved {
+ if r.Dependency == tomlRb {
+ found = true
+ break
+ }
+ }
+ for _, r := range report.Skipped {
+ if r.Dependency == tomlRb {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected toml-rb dependency, got %v",
report.Resolved)
+ }
+ })
+
+ t.Run("citrus case", func(t *testing.T) {
+ tmp := t.TempDir()
+ gemHome := filepath.Join(tmp, "gemhome")
+ specsDir := filepath.Join(gemHome, "specifications")
+ if err := os.MkdirAll(specsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("GEM_HOME", gemHome)
+
+ // Create toml-rb gemspec (dependency of toml-merge)
+ tomlRbContent := `
+Gem::Specification.new do |s|
+ s.name = 'toml-rb'
+ s.version = '1.0.0'
+ s.add_dependency 'citrus', '~> 3.0'
+end
+`
+ if err := writeFileRuby(filepath.Join(specsDir,
"toml-rb-1.0.0.gemspec"), tomlRbContent); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create citrus gemspec (dependency of toml-rb)
+ citrusContent := `
+Gem::Specification.new do |s|
+ s.name = 'citrus'
+ s.version = '3.0.2'
+ s.licenses = ['MIT']
+end
+`
+ if err := writeFileRuby(filepath.Join(specsDir,
"citrus-3.0.2.gemspec"), citrusContent); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create toml-merge gemspec (the project file)
+ tomlMergeContent := `
+Gem::Specification.new do |s|
+ s.name = 'toml-merge'
+ s.version = '0.0.1'
+ s.add_dependency 'toml-rb', '~> 1.0'
+end
+`
+ gemspec := filepath.Join(tmp, "toml-merge.gemspec")
+ if err := writeFileRuby(gemspec, tomlMergeContent); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := &ConfigDeps{Files: []string{gemspec}}
+ report := Report{}
+ if err := resolver.Resolve(gemspec, cfg, &report); err != nil {
+ t.Fatal(err)
+ }
+
+ // Check for citrus
+ found := false
+ var license string
+ for _, r := range report.Resolved {
+ if r.Dependency == citrus {
+ found = true
+ license = r.LicenseSpdxID
+ break
+ }
+ }
+ if !found {
+ // Check skipped
+ for _, r := range report.Skipped {
+ if r.Dependency == citrus {
+ found = true
+ license = r.LicenseSpdxID
+ break
+ }
+ }
+ }
+
+ if !found {
+ t.Error("expected citrus dependency (transitive)")
+ } else {
+ t.Logf("citrus license: %s", license)
+ if license != mit {
+ t.Errorf("expected citrus license MIT, got %s",
license)
+ }
+ }
+ })
+
+ t.Run("multiple licenses case (non-conflicting)", func(t *testing.T) {
+ testMultiLicense(t, resolver, "multi-license", "['MIT',
'Apache-2.0']", "MIT AND Apache-2.0")
+ })
+
+ t.Run("multiple licenses case (conflicting/incompatible)", func(t
*testing.T) {
+ testMultiLicense(t, resolver, "conflicting-license",
"['GPL-2.0', 'Apache-2.0']", "GPL-2.0 AND Apache-2.0")
+ })
+}
+
+func testMultiLicense(t *testing.T, resolver *GemspecResolver, gemName,
licensesStr, expectedLicense string) {
+ tmp := t.TempDir()
+ gemHome := filepath.Join(tmp, "gemhome")
+ specsDir := filepath.Join(gemHome, "specifications")
+ if err := os.MkdirAll(specsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ t.Setenv("GEM_HOME", gemHome)
+
+ // Create multi-license gem
+ gemContent := fmt.Sprintf(`
+Gem::Specification.new do |s|
+ s.name = '%s'
+ s.version = '1.0.0'
+ s.licenses = %s
+end
+`, gemName, licensesStr)
+ if err := writeFileRuby(filepath.Join(specsDir,
fmt.Sprintf("%s-1.0.0.gemspec", gemName)), gemContent); err != nil {
+ t.Fatal(err)
+ }
+
+ // Create project gemspec
+ projectContent := fmt.Sprintf(`
+Gem::Specification.new do |s|
+ s.name = 'project'
+ s.version = '0.0.1'
+ s.add_dependency '%s', '~> 1.0'
+end
+`, gemName)
+ gemspec := filepath.Join(tmp, "project.gemspec")
+ if err := writeFileRuby(gemspec, projectContent); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := &ConfigDeps{Files: []string{gemspec}}
+ report := Report{}
+ if err := resolver.Resolve(gemspec, cfg, &report); err != nil {
+ t.Fatal(err)
+ }
+
+ found := false
+ for _, r := range report.Resolved {
+ if r.Dependency == gemName {
+ found = true
+ if r.LicenseSpdxID != expectedLicense {
+ t.Errorf("expected %s license '%s', got '%s'",
gemName, expectedLicense, r.LicenseSpdxID)
+ }
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected %s dependency", gemName)
+ }
+}
diff --git a/pkg/deps/golang.go b/pkg/deps/golang.go
index ea32f0b..62d99d7 100644
--- a/pkg/deps/golang.go
+++ b/pkg/deps/golang.go
@@ -28,8 +28,8 @@ import (
"path/filepath"
"regexp"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/license"
+ "github.com/apache/skywalking-eyes/pkg/logger"
"golang.org/x/tools/go/packages"
)
diff --git a/pkg/deps/jar.go b/pkg/deps/jar.go
index cd93624..861c6c9 100644
--- a/pkg/deps/jar.go
+++ b/pkg/deps/jar.go
@@ -28,8 +28,8 @@ import (
"regexp"
"strings"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/license"
+ "github.com/apache/skywalking-eyes/pkg/logger"
"github.com/bmatcuk/doublestar/v2"
)
diff --git a/pkg/deps/jar_test.go b/pkg/deps/jar_test.go
index 520b5ba..df39fd3 100644
--- a/pkg/deps/jar_test.go
+++ b/pkg/deps/jar_test.go
@@ -24,8 +24,8 @@ import (
"path/filepath"
"testing"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/deps"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
func TestCanResolveJarFile(t *testing.T) {
diff --git a/pkg/deps/maven.go b/pkg/deps/maven.go
index 946e1f3..2e93b56 100644
--- a/pkg/deps/maven.go
+++ b/pkg/deps/maven.go
@@ -29,8 +29,8 @@ import (
"golang.org/x/net/html/charset"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/license"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
type MavenPomResolver struct {
diff --git a/pkg/deps/npm.go b/pkg/deps/npm.go
index 63bc102..b50c74c 100644
--- a/pkg/deps/npm.go
+++ b/pkg/deps/npm.go
@@ -29,8 +29,8 @@ import (
"strings"
"time"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/license"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
type NpmResolver struct {
diff --git a/pkg/deps/resolve.go b/pkg/deps/resolve.go
index 2551b6a..fe1a672 100644
--- a/pkg/deps/resolve.go
+++ b/pkg/deps/resolve.go
@@ -33,6 +33,7 @@ var Resolvers = []Resolver{
new(JarResolver),
new(CargoTomlResolver),
new(GemfileLockResolver),
+ new(GemspecResolver),
}
func Resolve(config *ConfigDeps, report *Report) error {
diff --git a/pkg/deps/ruby.go b/pkg/deps/ruby.go
index ec769fb..bfcda62 100644
--- a/pkg/deps/ruby.go
+++ b/pkg/deps/ruby.go
@@ -28,7 +28,10 @@ import (
"regexp"
"strconv"
"strings"
+ "sync"
"time"
+
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
// GemfileLockResolver resolves Ruby dependencies from Gemfile.lock
@@ -93,8 +96,10 @@ func (r *GemfileLockResolver) Resolve(lockfile string,
config *ConfigDeps, repor
for name := range include {
// Some roots may not exist in the specs graph (e.g.,
git-sourced gems)
var version string
+ var localPath string
if spec, ok := specs[name]; ok && spec != nil {
version = spec.Version
+ localPath = spec.LocalPath
}
if exclude, _ := config.IsExcluded(name, version); exclude {
continue
@@ -104,7 +109,30 @@ func (r *GemfileLockResolver) Resolve(lockfile string,
config *ConfigDeps, repor
continue
}
- licenseID, err := fetchRubyGemsLicense(name, version)
+ if localPath != "" {
+ baseDir, err := filepath.Abs(dir)
+ if err != nil {
+ logger.Log.WithError(err).Warn("failed to
resolve base directory for local gem path")
+ } else {
+ candidatePath :=
filepath.Clean(filepath.Join(baseDir, localPath))
+ if candidatePath == baseDir ||
strings.HasPrefix(candidatePath, baseDir+string(os.PathSeparator)) {
+ fullPath := candidatePath
+ license, err :=
fetchLocalLicense(fullPath, name)
+ if err == nil && license != "" {
+
report.Resolve(&Result{Dependency: name, LicenseSpdxID: license, Version:
version})
+ continue
+ }
+ } else {
+ logger.Log.WithField("path",
localPath).Warn("ignoring potentially unsafe local gem path outside project
directory")
+ }
+ }
+ }
+
+ licenseID := fetchInstalledLicense(name, version)
+ var err error
+ if licenseID == "" {
+ licenseID, err = fetchRubyGemsLicense(name, version)
+ }
if err != nil || licenseID == "" {
// Gracefully treat as unresolved license and record in
report
report.Skip(&Result{Dependency: name, LicenseSpdxID:
Unknown, Version: version})
@@ -116,88 +144,255 @@ func (r *GemfileLockResolver) Resolve(lockfile string,
config *ConfigDeps, repor
return nil
}
-// -------- Parsing Gemfile.lock --------
+// GemspecResolver resolves dependencies from a .gemspec file.
+// It extracts runtime dependencies defined in the gemspec and recursively
resolves
+// their transitive dependencies by looking up installed gems in the local
environment.
+type GemspecResolver struct {
+ Resolver
+}
-type gemSpec struct {
- Name string
- Version string
- Deps []string
+// CanResolve checks if the given file is a .gemspec file.
+func (r *GemspecResolver) CanResolve(file string) bool {
+ return strings.HasSuffix(file, ".gemspec")
}
-type gemGraph map[string]*gemSpec
+// Resolve parses the gemspec file, identifies runtime dependencies, and
resolves
+// them along with their transitive dependencies. It reports the found
dependencies
+// and their licenses.
+func (r *GemspecResolver) Resolve(file string, config *ConfigDeps, report
*Report) error {
+ deps, err := parseInitialDependencies(file)
+ if err != nil {
+ return err
+ }
-var (
- lockSpecHeader = regexp.MustCompile(`^\s{4}([a-zA-Z0-9_\-]+)
\(([^)]+)\)`) // rake (13.0.6)
- lockDepLine = regexp.MustCompile(`^\s{6}([a-zA-Z0-9_\-]+)(?:\s|$)`)
// activesupport (~> 6.1)
-)
+ if errResolve := resolveTransitiveDependencies(deps); errResolve != nil
{
+ return errResolve
+ }
-func parseGemfileLock(s string) (graph gemGraph, roots []string, err error) {
- scanner := bufio.NewScanner(strings.NewReader(s))
- scanner.Split(bufio.ScanLines)
- graph = make(gemGraph)
+ for name, version := range deps {
+ if exclude, _ := config.IsExcluded(name, version); exclude {
+ continue
+ }
+ if l, ok := config.GetUserConfiguredLicense(name, version); ok {
+ report.Resolve(&Result{Dependency: name, LicenseSpdxID:
l, Version: version})
+ continue
+ }
+
+ // Check installed gems first, then fallback to RubyGems API
+ licenseID := fetchInstalledLicense(name, version)
+ if licenseID == "" {
+ licenseID, err = fetchRubyGemsLicense(name, version)
+ }
+ if err != nil || licenseID == "" {
+ report.Skip(&Result{Dependency: name, LicenseSpdxID:
Unknown, Version: version})
+ continue
+ }
+ report.Resolve(&Result{Dependency: name, LicenseSpdxID:
licenseID, Version: version})
+ }
+ return nil
+}
- inSpecs := false
- inDeps := false
- var current *gemSpec
+func parseInitialDependencies(file string) (map[string]string, error) {
+ f, err := os.Open(file)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+ scanner := bufio.NewScanner(f)
+ deps := make(map[string]string) // name -> version constraint
for scanner.Scan() {
line := scanner.Text()
- if strings.HasPrefix(line, "GEM") {
- inSpecs = true
- inDeps = false
- current = nil
+ trimLeft := strings.TrimLeft(line, " \t")
+ if strings.HasPrefix(trimLeft, "#") {
continue
}
- if strings.HasPrefix(line, "DEPENDENCIES") {
- inSpecs = false
- inDeps = true
- current = nil
+ if m := gemspecRuntimeRe.FindStringSubmatch(line); len(m) == 2 {
+ // NOTE: Version constraints are currently ignored. We
resolve to the first found installed version of the gem.
+ // This may lead to incorrect resolution if multiple
versions are installed and the first one doesn't satisfy the constraint.
+ deps[m[1]] = ""
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+ return deps, nil
+}
+
+func resolveTransitiveDependencies(deps map[string]string) error {
+ queue := make([]string, 0, len(deps))
+ visited := make(map[string]struct{}, len(deps))
+ for name := range deps {
+ queue = append(queue, name)
+ visited[name] = struct{}{}
+ }
+
+ for i := 0; i < len(queue); i++ {
+ name := queue[i]
+ // Find installed gemspec for 'name'
+ path, err := findInstalledGemspec(name, "")
+ if err != nil {
+ logger.Log.Debugf("failed to find installed gemspec for
%s: %v", name, err)
continue
}
- if strings.TrimSpace(line) == "specs:" && inSpecs {
- // just a marker
+ if path == "" {
continue
}
- if inSpecs {
- if m := lockSpecHeader.FindStringSubmatch(line); len(m)
== 3 {
- name := m[1]
- version := m[2]
- current = &gemSpec{Name: name, Version: version}
- graph[name] = current
- continue
- }
- if current != nil {
- if m := lockDepLine.FindStringSubmatch(line);
len(m) == 2 {
- depName := m[1]
- current.Deps = append(current.Deps,
depName)
- }
- }
+ // Parse dependencies of this gemspec
+ newDeps, err := parseGemspecDependencies(path)
+ if err != nil {
+ logger.Log.Debugf("failed to parse gemspec dependencies
for %s at %s: %v", name, path, err)
continue
}
- if inDeps {
- trim := strings.TrimSpace(line)
- if trim == "" || strings.HasPrefix(trim, "BUNDLED
WITH") {
- inDeps = false
- continue
- }
- // dependency line: byebug (~> 11.1)
- root := trim
- if i := strings.Index(root, " "); i >= 0 {
- root = root[:i]
- }
- // ignore comments and platforms
- if root != "" && !strings.HasPrefix(root, "#") {
- roots = append(roots, root)
+ for _, dep := range newDeps {
+ if _, ok := visited[dep]; !ok {
+ if len(queue) >= 10000 {
+ return fmt.Errorf("dependency graph
exceeded maximum size of 10000 nodes (current: %d). "+
+ "This may indicate a circular
dependency or an unusually large dependency tree", len(queue))
+ }
+ visited[dep] = struct{}{}
+ queue = append(queue, dep)
+ if _, ok := deps[dep]; !ok {
+ deps[dep] = ""
+ }
}
- continue
}
}
+ return nil
+}
+
+// -------- Parsing Gemfile.lock --------
+
+type gemSpec struct {
+ Name string
+ Version string
+ Deps []string
+ LocalPath string
+}
+
+type gemGraph map[string]*gemSpec
+
+var (
+ lockSpecHeader = regexp.MustCompile(`^\s{4}([a-zA-Z0-9_\-]+)
\(([^)]+)\)`) // rake (13.0.6)
+ lockDepLine = regexp.MustCompile(`^\s{6}([a-zA-Z0-9_\-]+)(?:\s|$)`)
// activesupport (~> 6.1)
+)
+
+func parseGemfileLock(s string) (graph gemGraph, roots []string, err error) {
+ scanner := bufio.NewScanner(strings.NewReader(s))
+ scanner.Split(bufio.ScanLines)
+ graph = make(gemGraph)
+
+ state := &lockParserState{
+ graph: graph,
+ }
+
+ for scanner.Scan() {
+ state.processLine(scanner.Text())
+ }
if err := scanner.Err(); err != nil {
return nil, nil, err
}
- return graph, roots, nil
+ return graph, state.roots, nil
+}
+
+type lockParserState struct {
+ inSpecs bool
+ inDeps bool
+ inPath bool
+ current *gemSpec
+ currentLocalPath string
+ graph gemGraph
+ roots []string
+}
+
+func (s *lockParserState) processLine(line string) {
+ if strings.HasPrefix(line, "GEM") {
+ s.inSpecs = true
+ s.inDeps = false
+ s.inPath = false
+ s.currentLocalPath = ""
+ s.current = nil
+ return
+ }
+ if strings.HasPrefix(line, "PATH") {
+ s.inSpecs = true
+ s.inDeps = false
+ s.inPath = true
+ s.currentLocalPath = ""
+ s.current = nil
+ return
+ }
+ if strings.HasPrefix(line, "DEPENDENCIES") {
+ s.inSpecs = false
+ s.inDeps = true
+ s.inPath = false
+ s.current = nil
+ return
+ }
+ if strings.TrimSpace(line) == "specs:" && s.inSpecs {
+ // just a marker
+ return
+ }
+
+ if s.inSpecs {
+ s.processSpecs(line)
+ return
+ }
+
+ if s.inDeps {
+ s.processDeps(line)
+ return
+ }
+}
+
+func (s *lockParserState) processSpecs(line string) {
+ trim := strings.TrimSpace(line)
+ if strings.HasPrefix(trim, "remote:") {
+ // The inPath check ensures that only PATH block remote paths
are captured,
+ // not GEM block remote URLs (like gem.coop).
+ // This distinction is important for proper local dependency
resolution.
+ if s.inPath {
+ s.currentLocalPath =
strings.TrimSpace(strings.TrimPrefix(trim, "remote:"))
+ }
+ return
+ }
+
+ if m := lockSpecHeader.FindStringSubmatch(line); len(m) == 3 {
+ name := m[1]
+ version := m[2]
+ s.current = &gemSpec{Name: name, Version: version}
+ if s.inPath {
+ s.current.LocalPath = s.currentLocalPath
+ }
+ s.graph[name] = s.current
+ return
+ }
+ if s.current != nil {
+ if m := lockDepLine.FindStringSubmatch(line); len(m) == 2 {
+ depName := m[1]
+ s.current.Deps = append(s.current.Deps, depName)
+ }
+ }
+}
+
+func (s *lockParserState) processDeps(line string) {
+ trim := strings.TrimSpace(line)
+ if trim == "" || strings.HasPrefix(trim, "BUNDLED WITH") {
+ s.inDeps = false
+ return
+ }
+ // dependency line: byebug (~> 11.1)
+ root := trim
+ if i := strings.Index(root, " "); i >= 0 {
+ root = root[:i]
+ }
+ root = strings.TrimSuffix(root, "!")
+ // ignore comments and platforms
+ if root != "" && !strings.HasPrefix(root, "#") {
+ s.roots = append(s.roots, root)
+ }
}
func hasGemspec(dir string) bool {
@@ -214,6 +409,10 @@ func hasGemspec(dir string) bool {
}
var gemspecRuntimeRe =
regexp.MustCompile(`\badd_(?:runtime_)?dependency\s*\(?\s*["']([^"']+)["']`)
+var gemspecLicenseRe =
regexp.MustCompile(`\.licenses?\s*=\s*(\[[^\]]*\]|['"][^'"]*['"])`)
+var gemspecStringRe = regexp.MustCompile(`['"]([^'"]+)['"]`)
+var gemspecNameRe = regexp.MustCompile(`\.name\s*=\s*['"]([^'"]+)['"]`)
+var rubyVersionRe =
regexp.MustCompile(`^\d+(\.[0-9a-zA-Z]+)*(-[0-9a-zA-Z]+)?$`)
func runtimeDepsFromGemspecs(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
@@ -226,24 +425,12 @@ func runtimeDepsFromGemspecs(dir string) ([]string,
error) {
continue
}
path := filepath.Join(dir, e.Name())
- f, err := os.Open(path)
+ deps, err := parseGemspecDependencies(path)
if err != nil {
return nil, err
}
- scanner := bufio.NewScanner(f)
- for scanner.Scan() {
- line := scanner.Text()
- trimLeft := strings.TrimLeft(line, " \t")
- if strings.HasPrefix(trimLeft, "#") {
- continue // ignore commented-out lines
- }
- if m := gemspecRuntimeRe.FindStringSubmatch(line);
len(m) == 2 {
- runtime[m[1]] = struct{}{}
- }
- }
- _ = f.Close()
- if err := scanner.Err(); err != nil {
- return nil, err
+ for _, d := range deps {
+ runtime[d] = struct{}{}
}
}
res := make([]string, 0, len(runtime))
@@ -253,6 +440,216 @@ func runtimeDepsFromGemspecs(dir string) ([]string,
error) {
return res, nil
}
+func parseGemspecDependencies(path string) ([]string, error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer f.Close()
+
+ scanner := bufio.NewScanner(f)
+ var deps []string
+ for scanner.Scan() {
+ line := scanner.Text()
+ trimLeft := strings.TrimLeft(line, " \t")
+ if strings.HasPrefix(trimLeft, "#") {
+ continue
+ }
+ if m := gemspecRuntimeRe.FindStringSubmatch(line); len(m) == 2 {
+ deps = append(deps, m[1])
+ }
+ }
+ return deps, scanner.Err()
+}
+
+var (
+ gemspecsCache map[string][]string
+ gemspecsCacheLock sync.Mutex
+)
+
+func getAllGemspecs() []string {
+ env := os.Getenv("GEM_PATH")
+ if env == "" {
+ env = os.Getenv("GEM_HOME")
+ }
+
+ gemspecsCacheLock.Lock()
+ defer gemspecsCacheLock.Unlock()
+
+ if gemspecsCache == nil {
+ gemspecsCache = make(map[string][]string)
+ }
+
+ if cached, ok := gemspecsCache[env]; ok {
+ return cached
+ }
+
+ var allGemspecs []string
+ gemPaths := getGemPaths()
+ for _, dir := range gemPaths {
+ specsDir := filepath.Join(dir, "specifications")
+ entries, err := os.ReadDir(specsDir)
+ if err != nil {
+ continue
+ }
+ for _, e := range entries {
+ if !e.IsDir() && strings.HasSuffix(e.Name(),
".gemspec") {
+ allGemspecs = append(allGemspecs,
filepath.Join(specsDir, e.Name()))
+ }
+ }
+ }
+ gemspecsCache[env] = allGemspecs
+ return allGemspecs
+}
+
+func findInstalledGemspec(name, version string) (string, error) {
+ gems := getAllGemspecs()
+ for _, path := range gems {
+ filename := filepath.Base(path)
+ if version != "" && rubyVersionRe.MatchString(version) {
+ if filename == name+"-"+version+".gemspec" {
+ return path, nil
+ }
+ } else {
+ if !strings.HasPrefix(filename, name+"-") {
+ continue
+ }
+ stem := strings.TrimSuffix(filename, ".gemspec")
+ // Ensure that the character immediately after the
"name-" prefix
+ // is a digit, so we only consider filenames where the
suffix is
+ // a version component (e.g., "foo-1.0.0.gemspec") and
avoid
+ // similar names like "foo-bar-1.0.0.gemspec" when
searching for "foo".
+ if len(stem) <= len(name)+1 {
+ continue
+ }
+ versionStart := stem[len(name)+1]
+ if versionStart < '0' || versionStart > '9' {
+ continue
+ }
+
+ if specName, _, err := parseGemspecInfo(path); err ==
nil && specName == name {
+ return path, nil
+ }
+ }
+ }
+ return "", os.ErrNotExist
+}
+
+func fetchLocalLicense(dir, targetName string) (string, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return "", err
+ }
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".gemspec") {
+ continue
+ }
+ path := filepath.Join(dir, e.Name())
+ specName, license, err := parseGemspecInfo(path)
+ if err == nil && specName == targetName && license != "" {
+ return license, nil
+ }
+ }
+ return "", nil
+}
+
+func fetchInstalledLicense(name, version string) string {
+ if version != "" && !rubyVersionRe.MatchString(version) {
+ return ""
+ }
+ gems := getAllGemspecs()
+ for _, path := range gems {
+ filename := filepath.Base(path)
+ // If version is specific
+ if version != "" && rubyVersionRe.MatchString(version) {
+ if filename == name+"-"+version+".gemspec" {
+ if _, license, err := parseGemspecInfo(path);
err == nil && license != "" {
+ return license
+ }
+ }
+ } else {
+ // Scan for any version
+ if !strings.HasPrefix(filename, name+"-") {
+ continue
+ }
+ stem := strings.TrimSuffix(filename, ".gemspec")
+ ver := strings.TrimPrefix(stem, name+"-")
+ // Ensure the character after the gem name corresponds
to the start of a version
+ if ver == "" || ver[0] < '0' || ver[0] > '9' {
+ continue
+ }
+ if specName, license, err := parseGemspecInfo(path);
err == nil && specName == name && license != "" {
+ return license
+ }
+ }
+ }
+ return ""
+}
+
+func getGemPaths() []string {
+ env := os.Getenv("GEM_PATH")
+ if env == "" {
+ env = os.Getenv("GEM_HOME")
+ }
+ if env == "" {
+ return nil
+ }
+ return strings.Split(env, string(os.PathListSeparator))
+}
+
+func parseGemspecInfo(path string) (gemName, gemLicense string, err error) {
+ f, err := os.Open(path)
+ if err != nil {
+ return "", "", err
+ }
+ defer f.Close()
+ scanner := bufio.NewScanner(f)
+ var name, license string
+ for scanner.Scan() {
+ line := scanner.Text()
+ trimLeft := strings.TrimLeft(line, " \t")
+ if strings.HasPrefix(trimLeft, "#") {
+ continue
+ }
+ if name == "" {
+ if m := gemspecNameRe.FindStringSubmatch(line); len(m)
== 2 {
+ name = m[1]
+ }
+ }
+ if license == "" {
+ if m := gemspecLicenseRe.FindStringSubmatch(line);
len(m) == 2 {
+ matches :=
gemspecStringRe.FindAllStringSubmatch(m[1], -1)
+ var licenses []string
+ for _, match := range matches {
+ if len(match) == 2 {
+ licenses = append(licenses,
match[1])
+ }
+ }
+ if len(licenses) > 0 {
+ // NOTE: When multiple licenses are
declared in the gemspec, we assume they are
+ // all required ("AND") to be
conservative. If the author intended "OR",
+ // the user can override this in the
configuration.
+ if len(licenses) > 1 {
+ gemRef := name
+ if gemRef == "" {
+ gemRef = path
+ }
+ logger.Log.Warnf("Multiple
licenses found for gem %s: %v. Assuming 'AND' relationship for safety.",
gemRef, licenses)
+ }
+ license = strings.Join(licenses, " AND
")
+ }
+ }
+ }
+ if name != "" && license != "" {
+ break
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return "", "", err
+ }
+ return name, license, nil
+}
+
func reachable(graph gemGraph, roots []string) map[string]struct{} {
vis := make(map[string]struct{})
var dfs func(string)
@@ -286,17 +683,17 @@ type rubyGemsVersionInfo struct {
func fetchRubyGemsLicense(name, version string) (string, error) {
// If version is unknown (e.g., git-sourced), query latest gem info
endpoint
if strings.TrimSpace(version) == "" {
- url := fmt.Sprintf("https://rubygems.org/api/v1/gems/%s.json",
name)
+ url := fmt.Sprintf("https://gem.coop/api/v1/gems/%s.json", name)
return fetchRubyGemsLicenseFrom(url)
}
// Prefer version-specific API
- url :=
fmt.Sprintf("https://rubygems.org/api/v2/rubygems/%s/versions/%s.json", name,
version)
+ url :=
fmt.Sprintf("https://gem.coop/api/v2/rubygems/%s/versions/%s.json", name,
version)
licenseID, err := fetchRubyGemsLicenseFrom(url)
if err == nil && licenseID != "" {
return licenseID, nil
}
// Fallback to latest info
- url = fmt.Sprintf("https://rubygems.org/api/v1/gems/%s.json", name)
+ url = fmt.Sprintf("https://gem.coop/api/v1/gems/%s.json", name)
return fetchRubyGemsLicenseFrom(url)
}
diff --git a/pkg/deps/ruby_test.go b/pkg/deps/ruby_test.go
index f548f85..622ba24 100644
--- a/pkg/deps/ruby_test.go
+++ b/pkg/deps/ruby_test.go
@@ -20,6 +20,7 @@ package deps
import (
"bufio"
"embed"
+ "fmt"
"io"
"io/fs"
"net/http"
@@ -117,6 +118,67 @@ func TestRubyGemfileLockResolver(t *testing.T) {
t.Fatalf("expected 1 dependency for library, got %d",
len(report.Resolved)+len(report.Skipped))
}
}
+
+ // Citrus case: library with gemspec, no runtime deps
+ {
+ tmp := t.TempDir()
+ if err := copyRuby("testdata/ruby/citrus", tmp); err != nil {
+ t.Fatal(err)
+ }
+ lock := filepath.Join(tmp, "Gemfile.lock")
+ if !resolver.CanResolve(lock) {
+ t.Fatalf("GemfileLockResolver cannot resolve %s", lock)
+ }
+ cfg := &ConfigDeps{Files: []string{lock}}
+ report := Report{}
+ if err := resolver.Resolve(lock, cfg, &report); err != nil {
+ t.Fatal(err)
+ }
+ // Should have 0 dependencies because citrus has no runtime deps
+ if len(report.Resolved)+len(report.Skipped) != 0 {
+ t.Fatalf("expected 0 dependencies, got %d",
len(report.Resolved)+len(report.Skipped))
+ }
+ }
+
+ // Local dependency case: App depends on local gem (citrus)
+ {
+ tmp := t.TempDir()
+ if err := copyRuby("testdata/ruby/local_dep", tmp); err != nil {
+ t.Fatal(err)
+ }
+ lock := filepath.Join(tmp, "Gemfile.lock")
+ if !resolver.CanResolve(lock) {
+ t.Fatalf("GemfileLockResolver cannot resolve %s", lock)
+ }
+ cfg := &ConfigDeps{Files: []string{lock}}
+ report := Report{}
+ if err := resolver.Resolve(lock, cfg, &report); err != nil {
+ t.Fatal(err)
+ }
+
+ // We expect citrus to be resolved with MIT license.
+ // This validates that local path dependencies are correctly
resolved.
+ found := false
+ for _, r := range report.Resolved {
+ if r.Dependency == citrus {
+ found = true
+ if r.LicenseSpdxID != "MIT" {
+ t.Errorf("expected MIT license for
citrus, got %s", r.LicenseSpdxID)
+ }
+ }
+ }
+
+ if !found {
+ t.Fatal("expected citrus to be in Resolved
dependencies")
+ }
+
+ // Ensure it is not in Skipped
+ for _, r := range report.Skipped {
+ if r.Dependency == citrus {
+ t.Errorf("citrus found in Skipped dependencies,
expected it to be Resolved")
+ }
+ }
+ }
}
// mock RoundTripper to control HTTP responses
@@ -140,7 +202,7 @@ func TestRubyMissingSpecIsSkippedGracefully(t *testing.T) {
// Create a Gemfile.lock where a dependency is not present in specs
content := "" +
"GEM\n" +
- " remote: https://rubygems.org/\n" +
+ " remote: https://gem.coop/\n" +
" specs:\n" +
" rake (13.0.6)\n" +
"\n" +
@@ -195,7 +257,7 @@ func TestRubyLibraryWithNoRuntimeDependenciesIncludesNone(t
*testing.T) {
dir := t.TempDir()
lockContent := "" +
"GEM\n" +
- " remote: https://rubygems.org/\n" +
+ " remote: https://gem.coop/\n" +
" specs:\n" +
" rake (13.0.6)\n" +
" rspec (3.10.0)\n" +
@@ -250,7 +312,7 @@ func TestGemspecIgnoresCommentedRuntimeDependencies(t
*testing.T) {
dir := t.TempDir()
lockContent := "" +
"GEM\n" +
- " remote: https://rubygems.org/\n" +
+ " remote: https://gem.coop/\n" +
" specs:\n" +
" rake (13.0.6)\n" +
"\n" +
@@ -292,3 +354,133 @@ func TestGemspecIgnoresCommentedRuntimeDependencies(t
*testing.T) {
t.Fatalf("expected 0 dependencies when runtime deps are only
commented, got %d", got)
}
}
+
+func TestFetchInstalledLicense(t *testing.T) {
+ gemHome := t.TempDir()
+ specsDir := filepath.Join(gemHome, "specifications")
+ if err := os.MkdirAll(specsDir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ createGemspec := func(filename, name, version, license string) {
+ content := fmt.Sprintf(`
+Gem::Specification.new do |s|
+ s.name = "%s"
+ s.version = "%s"
+ s.licenses = ["%s"]
+end
+`, name, version, license)
+ if err := os.WriteFile(filepath.Join(specsDir, filename),
[]byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ createGemspec("foo-1.0.0.gemspec", "foo", "1.0.0", "MIT")
+ createGemspec("foo-2.0.0.gemspec", "foo", "2.0.0", "GPL-3.0")
+ createGemspec("foo-bar-1.0.0.gemspec", "foo-bar", "1.0.0", "Apache-2.0")
+ createGemspec("bar-1.0.0.gemspec", "bar", "1.0.0", "BSD-3-Clause")
+ // Invalid version string in filename
+ createGemspec("foo-invalid.gemspec", "foo", "invalid", "WTFPL")
+
+ t.Setenv("GEM_HOME", gemHome)
+
+ tests := []struct {
+ name string
+ version string
+ want string
+ }{
+ {"foo", "1.0.0", "MIT"},
+ {"foo", "2.0.0", "GPL-3.0"},
+ {"foo-bar", "1.0.0", "Apache-2.0"},
+ {"bar", "1.0.0", "BSD-3-Clause"},
+ {"foo", "", "MIT"}, // Should find first available version
(1.0.0 comes before 2.0.0)
+ {"foo", "3.0.0", ""}, // Not found
+ {"unknown", "1.0.0", ""},
+ {"foo", "invalid", ""}, // Invalid version requested, regex
won't match
+ }
+
+ for _, tt := range tests {
+ t.Run(fmt.Sprintf("%s-%s", tt.name, tt.version), func(t
*testing.T) {
+ got := fetchInstalledLicense(tt.name, tt.version)
+ if got != tt.want {
+ t.Errorf("fetchInstalledLicense(%q, %q) = %q,
want %q", tt.name, tt.version, got, tt.want)
+ }
+ })
+ }
+}
+
+func TestRubyGemfileLockResolver_PathTraversal(t *testing.T) {
+ const evil = "evil"
+ resolver := new(GemfileLockResolver)
+ dir := t.TempDir()
+
+ // Create a directory outside the project
+ outsideDir := t.TempDir()
+ // Create a gemspec in outsideDir
+ outsideGemspec := filepath.Join(outsideDir, "evil.gemspec")
+ if err := os.WriteFile(outsideGemspec, []byte(`
+Gem::Specification.new do |s|
+ s.name = "evil"
+ s.version = "1.0.0"
+ s.licenses = ["Evil-License"]
+end
+`), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ // Calculate relative path from dir to outsideDir
+ relPath, err := filepath.Rel(dir, outsideDir)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ lockContent := fmt.Sprintf(`
+PATH
+ remote: %s
+ specs:
+ evil (1.0.0)
+
+GEM
+ remote: https://gem.coop/
+ specs:
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ evil!
+
+BUNDLED WITH
+ 2.4.10
+`, relPath)
+
+ lock := filepath.Join(dir, "Gemfile.lock")
+ if err := writeFileRuby(lock, lockContent); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := &ConfigDeps{Files: []string{lock}}
+ report := Report{}
+ if err := resolver.Resolve(lock, cfg, &report); err != nil {
+ t.Fatal(err)
+ }
+
+ found := false
+ for _, r := range report.Resolved {
+ if r.Dependency == evil {
+ found = true
+ if r.LicenseSpdxID == "Evil-License" {
+ t.Errorf("Path traversal succeeded! Found
license from outside directory.")
+ }
+ }
+ }
+ // If it's skipped, that's also fine (means it didn't resolve license)
+ for _, r := range report.Skipped {
+ if r.Dependency == evil {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("Dependency 'evil' was not found in report")
+ }
+}
diff --git a/pkg/deps/testdata/ruby/app/Gemfile.lock
b/pkg/deps/testdata/ruby/app/Gemfile.lock
index 7d6478b..78cb83b 100644
--- a/pkg/deps/testdata/ruby/app/Gemfile.lock
+++ b/pkg/deps/testdata/ruby/app/Gemfile.lock
@@ -1,5 +1,5 @@
GEM
- remote: https://rubygems.org/
+ remote: https://gem.coop/
specs:
rake (13.0.6)
rspec (3.10.0)
diff --git a/pkg/deps/testdata/ruby/citrus/Gemfile.lock
b/pkg/deps/testdata/ruby/citrus/Gemfile.lock
new file mode 100644
index 0000000..e63e4a3
--- /dev/null
+++ b/pkg/deps/testdata/ruby/citrus/Gemfile.lock
@@ -0,0 +1,24 @@
+PATH
+ remote: .
+ specs:
+ citrus (3.0.2)
+ rake
+ test-unit
+
+GEM
+ remote: https://gem.coop/
+ specs:
+ power_assert (2.0.1)
+ rake (13.0.6)
+ test-unit (3.4.4)
+ power_assert
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ citrus!
+
+BUNDLED WITH
+ 2.2.15
+
diff --git a/pkg/deps/testdata/ruby/citrus/citrus.gemspec
b/pkg/deps/testdata/ruby/citrus/citrus.gemspec
new file mode 100644
index 0000000..41e4970
--- /dev/null
+++ b/pkg/deps/testdata/ruby/citrus/citrus.gemspec
@@ -0,0 +1,53 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+$LOAD_PATH.unshift(File.expand_path('../lib', __FILE__))
+
+require 'citrus/version'
+
+Gem::Specification.new do |s|
+ s.name = 'citrus'
+ s.version = Citrus.version
+ s.date = Time.now.strftime('%Y-%m-%d')
+
+ s.summary = 'Parsing Expressions for Ruby'
+ s.description = 'Parsing Expressions for Ruby'
+
+ s.author = 'Michael Jackson'
+ s.email = '[email protected]'
+
+ s.require_paths = %w< lib >
+
+ s.files = Dir['benchmark/**'] +
+ Dir['doc/**'] +
+ Dir['extras/**'] +
+ Dir['lib/**/*.rb'] +
+ Dir['test/**/*'] +
+ %w< citrus.gemspec Rakefile README.md CHANGES >
+
+ s.test_files = s.files.select {|path| path =~ /^test\/.*_test.rb/ }
+
+ s.add_development_dependency('rake')
+ s.add_development_dependency('test-unit')
+
+ s.rdoc_options = %w< --line-numbers --inline-source --title Citrus --main
Citrus >
+ s.extra_rdoc_files = %w< README.md CHANGES >
+
+ s.homepage = 'http://mjackson.github.io/citrus'
+ s.licenses = ['MIT']
+end
+
diff --git a/pkg/deps/testdata/ruby/library/Gemfile.lock
b/pkg/deps/testdata/ruby/library/Gemfile.lock
index 7d6478b..78cb83b 100644
--- a/pkg/deps/testdata/ruby/library/Gemfile.lock
+++ b/pkg/deps/testdata/ruby/library/Gemfile.lock
@@ -1,5 +1,5 @@
GEM
- remote: https://rubygems.org/
+ remote: https://gem.coop/
specs:
rake (13.0.6)
rspec (3.10.0)
diff --git a/pkg/deps/testdata/ruby/local_dep/Gemfile.lock
b/pkg/deps/testdata/ruby/local_dep/Gemfile.lock
new file mode 100644
index 0000000..bac6a89
--- /dev/null
+++ b/pkg/deps/testdata/ruby/local_dep/Gemfile.lock
@@ -0,0 +1,22 @@
+PATH
+ remote: citrus
+ specs:
+ citrus (3.0.2)
+ rake
+ test-unit
+
+GEM
+ remote: https://gem.coop/
+ specs:
+ rake (13.0.6)
+ test-unit (3.4.4)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ citrus!
+
+BUNDLED WITH
+ 2.2.15
+
diff --git a/pkg/deps/testdata/ruby/local_dep/citrus/citrus.gemspec
b/pkg/deps/testdata/ruby/local_dep/citrus/citrus.gemspec
new file mode 100644
index 0000000..71266dc
--- /dev/null
+++ b/pkg/deps/testdata/ruby/local_dep/citrus/citrus.gemspec
@@ -0,0 +1,25 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+Gem::Specification.new do |s|
+ s.name = 'citrus'
+ s.version = '3.0.2'
+ s.licenses = ['MIT']
+ s.add_development_dependency('rake')
+ s.add_development_dependency('test-unit')
+end
+
diff --git a/pkg/deps/testdata/ruby/toml-merge/toml-merge.gemspec
b/pkg/deps/testdata/ruby/toml-merge/toml-merge.gemspec
new file mode 100644
index 0000000..14dad39
--- /dev/null
+++ b/pkg/deps/testdata/ruby/toml-merge/toml-merge.gemspec
@@ -0,0 +1,118 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+Gem::Specification.new do |spec|
+ spec.name = "toml-merge"
+ spec.version = Module.new.tap { |mod|
Kernel.load("#{__dir__}/lib/toml/merge/version.rb", mod)
}::Toml::Merge::Version::VERSION
+ spec.authors = ["Peter H. Boling"]
+ spec.email = ["[email protected]"]
+
+ spec.summary = "☯️ TOML file smart merge using tree-sitter AST analysis"
+ spec.description = "☯️ Intelligently merges TOML files by analyzing their
AST structure with tree-sitter, preserving key organization and resolving
conflicts based on structural similarity."
+ spec.homepage = "https://github.com/kettle-rb/toml-merge"
+ spec.licenses = ["MIT"]
+ spec.required_ruby_version = ">= 3.2.0"
+
+ # Linux distros often package gems and securely certify them independent
+ # of the official RubyGem certification process. Allowed via
ENV["SKIP_GEM_SIGNING"]
+ # Ref: https://gitlab.com/ruby-oauth/version_gem/-/issues/3
+ # Hence, only enable signing if `SKIP_GEM_SIGNING` is not set in ENV.
+ # See CONTRIBUTING.md
+ unless ENV.include?("SKIP_GEM_SIGNING")
+ user_cert = "certs/#{ENV.fetch("GEM_CERT_USER", ENV["USER"])}.pem"
+ cert_file_path = File.join(__dir__, user_cert)
+ cert_chain = cert_file_path.split(",")
+ cert_chain.select! { |fp| File.exist?(fp) }
+ if cert_file_path && cert_chain.any?
+ spec.cert_chain = cert_chain
+ if $PROGRAM_NAME.end_with?("gem") && ARGV[0] == "build"
+ spec.signing_key = File.join(Gem.user_home, ".ssh",
"gem-private_key.pem")
+ end
+ end
+ end
+
+ spec.metadata["homepage_uri"] = "https://#{spec.name.tr("_",
"-")}.galtzo.com/"
+ spec.metadata["source_code_uri"] = "#{spec.homepage}/tree/v#{spec.version}"
+ spec.metadata["changelog_uri"] =
"#{spec.homepage}/blob/v#{spec.version}/CHANGELOG.md"
+ spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
+ spec.metadata["documentation_uri"] =
"https://www.rubydoc.info/gems/#{spec.name}/#{spec.version}"
+ spec.metadata["funding_uri"] = "https://github.com/sponsors/pboling"
+ spec.metadata["wiki_uri"] = "#{spec.homepage}/wiki"
+ spec.metadata["news_uri"] = "https://www.railsbling.com/tags/#{spec.name}"
+ spec.metadata["discord_uri"] = "https://discord.gg/3qme4XHNKN"
+ spec.metadata["rubygems_mfa_required"] = "true"
+
+ # Specify which files are part of the released package.
+ spec.files = Dir[
+ # Code / tasks / data (NOTE: exe/ is specified via spec.bindir and
spec.executables below)
+ "lib/**/*.rb",
+ "lib/**/*.rake",
+ # Signatures
+ "sig/**/*.rbs",
+ ]
+
+ # Automatically included with gem package, no need to list again in files.
+ spec.extra_rdoc_files = Dir[
+ # Files (alphabetical)
+ "CHANGELOG.md",
+ "CITATION.cff",
+ "CODE_OF_CONDUCT.md",
+ "CONTRIBUTING.md",
+ "FUNDING.md",
+ "LICENSE.txt",
+ "README.md",
+ "REEK",
+ "RUBOCOP.md",
+ "SECURITY.md",
+ ]
+ spec.rdoc_options += [
+ "--title",
+ "#{spec.name} - #{spec.summary}",
+ "--main",
+ "README.md",
+ "--exclude",
+ "^sig/",
+ "--line-numbers",
+ "--inline-source",
+ "--quiet",
+ ]
+ spec.require_paths = ["lib"]
+ spec.bindir = "exe"
+ # Listed files are the relative paths from bindir above.
+ spec.executables = []
+
+ # Parsers
+ spec.add_dependency("toml-rb", "~> 4.1") # ruby
>= 2.3.0
+ spec.add_dependency("tree_haver", "~> 3.1") # ruby
>= 3.2.0
+
+ # Shared merge infrastructure
+ spec.add_dependency("ast-merge", "~> 1.1") # ruby
>= 3.2.0
+
+ # Utilities
+ spec.add_dependency("version_gem", "~> 1.1", ">= 1.1.9") # ruby
>= 2.2.0
+
+ # Development and testing
+ spec.add_development_dependency("kettle-dev", "~> 1.1")
# ruby >= 2.3.0
+ spec.add_development_dependency("bundler-audit", "~> 0.9.2")
# ruby >= 2.0.0
+ spec.add_development_dependency("rake", "~> 13.0")
# ruby >= 2.2.0
+ spec.add_development_dependency("require_bench", "~> 1.0", ">= 1.0.4")
# ruby >= 2.2.0
+ spec.add_development_dependency("appraisal2", "~> 3.0")
# ruby >= 1.8.7, for testing against multiple versions of dependencies
+ spec.add_development_dependency("kettle-test", "~> 1.0", ">= 1.0.6")
# ruby >= 2.3
+ spec.add_development_dependency("ruby-progressbar", "~> 1.13")
# ruby >= 0
+ spec.add_development_dependency("stone_checksums", "~> 1.0", ">= 1.0.2")
# ruby >= 2.2.0
+ spec.add_development_dependency("gitmoji-regex", "~> 1.0", ">= 1.0.3")
# ruby >= 2.3.0
+end
diff --git a/pkg/header/check.go b/pkg/header/check.go
index 9c0318b..1644c89 100644
--- a/pkg/header/check.go
+++ b/pkg/header/check.go
@@ -26,9 +26,9 @@ import (
"regexp"
"strings"
- "github.com/apache/skywalking-eyes/internal/logger"
eyeignore "github.com/apache/skywalking-eyes/pkg/gitignore"
lcs "github.com/apache/skywalking-eyes/pkg/license"
+ "github.com/apache/skywalking-eyes/pkg/logger"
"github.com/bmatcuk/doublestar/v2"
"github.com/go-git/go-billy/v5/osfs"
diff --git a/pkg/header/config.go b/pkg/header/config.go
index f7bc0fa..98438e7 100644
--- a/pkg/header/config.go
+++ b/pkg/header/config.go
@@ -26,9 +26,9 @@ import (
"time"
"github.com/apache/skywalking-eyes/assets"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/comments"
"github.com/apache/skywalking-eyes/pkg/license"
+ "github.com/apache/skywalking-eyes/pkg/logger"
"github.com/bmatcuk/doublestar/v2"
)
diff --git a/pkg/header/fix.go b/pkg/header/fix.go
index 984f560..232aa7e 100644
--- a/pkg/header/fix.go
+++ b/pkg/header/fix.go
@@ -24,8 +24,8 @@ import (
"regexp"
"strings"
- "github.com/apache/skywalking-eyes/internal/logger"
"github.com/apache/skywalking-eyes/pkg/comments"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
// Fix adds the configured license header to the given file.
diff --git a/pkg/license/identifier.go b/pkg/license/identifier.go
index d12376b..7a1df9f 100644
--- a/pkg/license/identifier.go
+++ b/pkg/license/identifier.go
@@ -27,7 +27,7 @@ import (
"gopkg.in/yaml.v3"
"github.com/apache/skywalking-eyes/assets"
- "github.com/apache/skywalking-eyes/internal/logger"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
const licenseTemplatesDir = "lcs-templates"
diff --git a/pkg/license/norm.go b/pkg/license/norm.go
index c9761d5..350d8ca 100644
--- a/pkg/license/norm.go
+++ b/pkg/license/norm.go
@@ -23,7 +23,7 @@ import (
"runtime"
"strings"
- "github.com/apache/skywalking-eyes/internal/logger"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
type Normalizer func(string) string
diff --git a/internal/logger/log.go b/pkg/logger/log.go
similarity index 100%
rename from internal/logger/log.go
rename to pkg/logger/log.go
diff --git a/pkg/review/header.go b/pkg/review/header.go
index d0c4862..f4a9594 100644
--- a/pkg/review/header.go
+++ b/pkg/review/header.go
@@ -31,9 +31,9 @@ import (
"github.com/google/go-github/v33/github"
"golang.org/x/oauth2"
- "github.com/apache/skywalking-eyes/internal/logger"
comments2 "github.com/apache/skywalking-eyes/pkg/comments"
header2 "github.com/apache/skywalking-eyes/pkg/header"
+ "github.com/apache/skywalking-eyes/pkg/logger"
)
var (