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 (

Reply via email to