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 3655e78 Ruby dependency license scanning support via Gemfile.lock.
(#205)
3655e78 is described below
commit 3655e78854be6a56ae72a0c9a847aedd133c3b74
Author: |7eter l-|. l3oling <[email protected]>
AuthorDate: Thu Sep 11 04:01:53 2025 -0600
Ruby dependency license scanning support via Gemfile.lock. (#205)
* Ruby dependency license scanning support via Gemfile.lock.
- Implements https://github.com/apache/skywalking/issues/7744
- Library projects (with a *.gemspec in the same directory as Gemfile.lock)
ignore development dependencies and include runtime dependencies and their
transitives.
- App projects (no *.gemspec) include both runtime and development
dependencies from Gemfile.lock.
- Will only work if Gemfile.lock is committed to version control, but this
is the official recommendation of RubyGems:
- https://bundler.io/guides/faq.html#using-gemfiles-inside-gems
- License resolution honors user overrides/exclusions and may query the
RubyGems API when necessary, with proper support for handling of various status
codes.
- Documentation updated (README.md)
- Ruby setup and GitHub Actions example are in <details> tag to reduce
noise
---
.licenserc.yaml | 2 +
README.md | 35 +++
pkg/deps/resolve.go | 1 +
pkg/deps/ruby.go | 415 ++++++++++++++++++++++++++
pkg/deps/ruby_test.go | 120 ++++++++
pkg/deps/testdata/ruby/app/Gemfile.lock | 17 ++
pkg/deps/testdata/ruby/library/Gemfile.lock | 17 ++
pkg/deps/testdata/ruby/library/sample.gemspec | 28 ++
8 files changed, 635 insertions(+)
diff --git a/.licenserc.yaml b/.licenserc.yaml
index 8acde6a..8f3576b 100644
--- a/.licenserc.yaml
+++ b/.licenserc.yaml
@@ -76,6 +76,8 @@ header: # `header` section is configurations for source codes
license header.
- "**/assets/assets.gen.go"
- "docs/**.svg"
- "pkg/gitignore/dir.go"
+ - "pkg/deps/testdata/ruby/app/Gemfile.lock"
+ - "pkg/deps/testdata/ruby/library/Gemfile.lock"
comment: on-failure # on what condition license-eye will comment on the pull
request, `on-failure`, `always`, `never`.
diff --git a/README.md b/README.md
index c55e8f7..c1946f8 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,7 @@ dependency:
- Cargo.toml # If this is a rust project.
- package.json # If this is a npm project.
- go.mod # If this is a Go project.
+ - Gemfile.lock # If this is a Ruby project (Bundler). Ensure
Gemfile.lock is committed.
```
#### Check License Headers
@@ -102,6 +103,40 @@ To check dependencies license in GitHub Actions, add a
step in your GitHub workf
# flags: # optional: Extra flags appended to the command, for example,
`--summary=path/to/template.tmpl`
```
+<details>
+<summary>Ruby projects (Bundler)</summary>
+
+License-Eye can resolve Ruby dependencies and their licenses directly from
Gemfile.lock.
+
+Rules applied:
+- If a .gemspec file exists in the same directory as Gemfile.lock, the project
is treated as a library and development dependencies are ignored. Runtime
dependencies (and their transitives) are included.
+- If no .gemspec is present, the project is treated as an app and all
dependencies from Gemfile.lock are considered (both runtime and development).
+
+Requirements:
+- Commit Gemfile.lock to version control so License-Eye can read the locked
dependency graph.
+- For libraries, ensure the .gemspec is present in the same directory as
Gemfile.lock.
+
+Minimal config snippet:
+
+```yaml
+dependency:
+ files:
+ - Gemfile.lock
+```
+
+GitHub Actions example:
+
+```yaml
+- name: Check Ruby dependencies' licenses
+ uses: apache/skywalking-eyes/dependency@main
+ with:
+ config: .licenserc.yaml
+```
+
+Note: License-Eye may query the RubyGems API to determine licenses when they
are not specified in your configuration. Ensure the workflow has network access.
+
+</details>
+
### Docker Image
For Bash, users can execute the following command,
diff --git a/pkg/deps/resolve.go b/pkg/deps/resolve.go
index fc2f562..2551b6a 100644
--- a/pkg/deps/resolve.go
+++ b/pkg/deps/resolve.go
@@ -32,6 +32,7 @@ var Resolvers = []Resolver{
new(MavenPomResolver),
new(JarResolver),
new(CargoTomlResolver),
+ new(GemfileLockResolver),
}
func Resolve(config *ConfigDeps, report *Report) error {
diff --git a/pkg/deps/ruby.go b/pkg/deps/ruby.go
new file mode 100644
index 0000000..5bbe5a5
--- /dev/null
+++ b/pkg/deps/ruby.go
@@ -0,0 +1,415 @@
+// 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 (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// GemfileLockResolver resolves Ruby dependencies from Gemfile.lock
+// It determines project type by the presence of a *.gemspec file in the same
directory as Gemfile.lock.
+// - Library projects (with gemspec): ignore development dependencies; include
only runtime deps and their transitive closure.
+// - App projects (no gemspec): include all dependencies in Gemfile.lock.
+// Licenses are fetched from RubyGems API unless overridden by user config.
+// See issue description for detailed rules.
+
+type GemfileLockResolver struct {
+ Resolver
+}
+
+func (r *GemfileLockResolver) CanResolve(file string) bool {
+ base := filepath.Base(file)
+ return base == "Gemfile.lock"
+}
+
+func (r *GemfileLockResolver) Resolve(lockfile string, config *ConfigDeps,
report *Report) error {
+ dir := filepath.Dir(lockfile)
+
+ content, err := os.ReadFile(lockfile)
+ if err != nil {
+ return err
+ }
+
+ // Parse lockfile into specs graph and top-level dependencies
+ specs, deps, err := parseGemfileLock(string(content))
+ if err != nil {
+ return err
+ }
+
+ isLibrary := hasGemspec(dir)
+
+ var roots []string
+ if isLibrary {
+ // Extract runtime dependencies from gemspec(s)
+ runtimeRoots, err := runtimeDepsFromGemspecs(dir)
+ if err != nil {
+ return err
+ }
+ if len(runtimeRoots) == 0 {
+ // Fallback: if not found, use DEPENDENCIES from
lockfile
+ roots = deps
+ } else {
+ roots = runtimeRoots
+ }
+ } else {
+ // App: all declared dependencies are relevant
+ roots = deps
+ }
+
+ // Compute the set of included gems
+ include := reachable(specs, roots)
+ // For app without explicit deps (rare), include all specs
+ if len(roots) == 0 {
+ for name := range specs {
+ include[name] = struct{}{}
+ }
+ }
+
+ // Resolve licenses for included gems
+ for name := range include {
+ version := specs[name].Version
+ 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
+ }
+
+ 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
+}
+
+// -------- Parsing Gemfile.lock --------
+
+type gemSpec struct {
+ Name string
+ Version string
+ Deps []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)
+
+ inSpecs := false
+ inDeps := false
+ var current *gemSpec
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.HasPrefix(line, "GEM") {
+ inSpecs = true
+ inDeps = false
+ current = nil
+ continue
+ }
+ if strings.HasPrefix(line, "DEPENDENCIES") {
+ inSpecs = false
+ inDeps = true
+ current = nil
+ continue
+ }
+ if strings.TrimSpace(line) == "specs:" && inSpecs {
+ // just a marker
+ 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)
+ }
+ }
+ 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)
+ }
+ continue
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, nil, err
+ }
+ return graph, roots, nil
+}
+
+func hasGemspec(dir string) bool {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return false
+ }
+ for _, e := range entries {
+ if !e.IsDir() && strings.HasSuffix(e.Name(), ".gemspec") {
+ return true
+ }
+ }
+ return false
+}
+
+var gemspecRuntimeRe =
regexp.MustCompile(`(?m)\badd_(?:runtime_)?dependency\s*\(?\s*["']([^"']+)["']`)
+
+func runtimeDepsFromGemspecs(dir string) ([]string, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ runtime := make(map[string]struct{})
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".gemspec") {
+ continue
+ }
+ b, err := os.ReadFile(filepath.Join(dir, e.Name()))
+ if err != nil {
+ return nil, err
+ }
+ for _, m := range
gemspecRuntimeRe.FindAllStringSubmatch(string(b), -1) {
+ if len(m) == 2 {
+ runtime[m[1]] = struct{}{}
+ }
+ }
+ }
+ res := make([]string, 0, len(runtime))
+ for k := range runtime {
+ res = append(res, k)
+ }
+ return res, nil
+}
+
+func reachable(graph gemGraph, roots []string) map[string]struct{} {
+ vis := make(map[string]struct{})
+ var dfs func(string)
+ dfs = func(n string) {
+ if _, ok := vis[n]; ok {
+ return
+ }
+ if _, ok := graph[n]; !ok {
+ // unknown in specs, still include the root
+ vis[n] = struct{}{}
+ return
+ }
+ vis[n] = struct{}{}
+ for _, c := range graph[n].Deps {
+ dfs(c)
+ }
+ }
+ for _, r := range roots {
+ dfs(r)
+ }
+ return vis
+}
+
+// -------- License resolution via RubyGems API --------
+
+type rubyGemsVersionInfo struct {
+ Licenses []string `json:"licenses"`
+ License string `json:"license"`
+}
+
+func fetchRubyGemsLicense(name, version string) (string, error) {
+ // Prefer version-specific API
+ url :=
fmt.Sprintf("https://rubygems.org/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)
+ return fetchRubyGemsLicenseFrom(url)
+}
+
+var httpClientRuby = &http.Client{Timeout: 10 * time.Second}
+
+func fetchRubyGemsLicenseFrom(url string) (string, error) {
+ const maxAttempts = 3
+ backoff := 1 * time.Second
+
+ for attempt := 1; attempt <= maxAttempts; attempt++ {
+ req, err := http.NewRequest(http.MethodGet, url, http.NoBody)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("User-Agent", "skywalking-eyes/License-Eye
(+https://github.com/apache/skywalking-eyes)")
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := httpClientRuby.Do(req) // #nosec G107
+ if err != nil {
+ if attempt == maxAttempts {
+ return "", err
+ }
+ time.Sleep(backoff)
+ backoff *= 2
+ continue
+ }
+
+ license, wait, retry, hErr := handleRubyGemsResponse(resp)
+ _ = resp.Body.Close()
+
+ if hErr != nil {
+ if retry && attempt < maxAttempts {
+ if wait > 0 {
+ time.Sleep(wait)
+ } else {
+ time.Sleep(backoff)
+ }
+ backoff *= 2
+ continue
+ }
+ return "", hErr
+ }
+
+ if retry { // safety branch, normally handled above when hErr
!= nil
+ if attempt == maxAttempts {
+ return "", fmt.Errorf("max attempts reached")
+ }
+ if wait > 0 {
+ time.Sleep(wait)
+ } else {
+ time.Sleep(backoff)
+ }
+ backoff *= 2
+ continue
+ }
+
+ return license, nil
+ }
+ return "", nil
+}
+
+func handleRubyGemsResponse(resp *http.Response) (license string, wait
time.Duration, retry bool, err error) {
+ switch {
+ case resp.StatusCode == http.StatusOK:
+ license, err := parseRubyGemsLicenseJSON(resp.Body)
+ return license, 0, false, err
+ case resp.StatusCode == http.StatusNotFound:
+ // Treat as no license info available
+ return "", 0, false, nil
+ case resp.StatusCode == http.StatusTooManyRequests || (resp.StatusCode
>= 500 && resp.StatusCode <= 599):
+ wait := retryAfterDuration(resp.Header.Get("Retry-After"))
+ return "", wait, true, fmt.Errorf("retryable status: %s",
resp.Status)
+ default:
+ return "", 0, false, fmt.Errorf("unexpected status: %s",
resp.Status)
+ }
+}
+
+func parseRubyGemsLicenseJSON(r io.Reader) (string, error) {
+ var info rubyGemsVersionInfo
+ dec := json.NewDecoder(r)
+ if err := dec.Decode(&info); err != nil {
+ return "", err
+ }
+ var items []string
+ if len(info.Licenses) > 0 {
+ items = info.Licenses
+ } else if info.License != "" {
+ items = []string{info.License}
+ }
+ for i := range items {
+ items[i] = strings.TrimSpace(items[i])
+ }
+ m := make(map[string]struct{})
+ for _, it := range items {
+ if it == "" {
+ continue
+ }
+ m[it] = struct{}{}
+ }
+ if len(m) == 0 {
+ return "", nil
+ }
+ var out []string
+ for k := range m {
+ out = append(out, k)
+ }
+ slicesSort(out)
+ return strings.Join(out, " OR "), nil
+}
+
+func retryAfterDuration(v string) time.Duration {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ return 0
+ }
+ if secs, err := strconv.Atoi(v); err == nil {
+ wait := time.Duration(secs) * time.Second
+ if wait > 10*time.Second {
+ wait = 10 * time.Second
+ }
+ return wait
+ }
+ return 0
+}
+
+// small helper to sort string slice without importing sort here to keep
imports aligned with style used in this package
+func slicesSort(ss []string) {
+ // simple insertion sort for small slices
+ for i := 1; i < len(ss); i++ {
+ j := i
+ for j > 0 && ss[j-1] > ss[j] {
+ ss[j-1], ss[j] = ss[j], ss[j-1]
+ j--
+ }
+ }
+}
diff --git a/pkg/deps/ruby_test.go b/pkg/deps/ruby_test.go
new file mode 100644
index 0000000..889a73d
--- /dev/null
+++ b/pkg/deps/ruby_test.go
@@ -0,0 +1,120 @@
+// 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_test
+
+import (
+ "bufio"
+ "embed"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/apache/skywalking-eyes/pkg/deps"
+)
+
+func writeFileRuby(fileName, content string) error {
+ file, err := os.OpenFile(fileName, os.O_WRONLY|os.O_TRUNC|os.O_CREATE,
0777)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = file.Close() }()
+
+ write := bufio.NewWriter(file)
+ _, err = write.WriteString(content)
+ if err != nil {
+ return err
+ }
+ _ = write.Flush()
+ return nil
+}
+
+func ensureDirRuby(dirName string) error {
+ return os.MkdirAll(dirName, 0777)
+}
+
+//go:embed testdata/ruby/**/*
+var rubyTestAssets embed.FS
+
+func copyRuby(assetDir, destination string) error {
+ return fs.WalkDir(rubyTestAssets, assetDir, func(path string, d
fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ filename := filepath.Join(destination, strings.Replace(path,
assetDir, "", 1))
+ if err := ensureDirRuby(filepath.Dir(filename)); err != nil {
+ return err
+ }
+ content, err := rubyTestAssets.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return writeFileRuby(filename, string(content))
+ })
+}
+
+func TestRubyGemfileLockResolver(t *testing.T) {
+ resolver := new(deps.GemfileLockResolver)
+
+ // App case: include all specs (3)
+ {
+ tmp := t.TempDir()
+ if err := copyRuby("testdata/ruby/app", tmp); err != nil {
+ t.Fatal(err)
+ }
+ lock := filepath.Join(tmp, "Gemfile.lock")
+ if !resolver.CanResolve(lock) {
+ t.Fatalf("GemfileLockResolver cannot resolve %s", lock)
+ }
+ cfg := &deps.ConfigDeps{Files: []string{lock}, Licenses:
[]*deps.ConfigDepLicense{
+ {Name: "rake", Version: "13.0.6", License: "MIT"},
+ {Name: "rspec", Version: "3.10.0", License: "MIT"},
+ {Name: "rspec-core", Version: "3.10.1", License: "MIT"},
+ }}
+ report := deps.Report{}
+ if err := resolver.Resolve(lock, cfg, &report); err != nil {
+ t.Fatal(err)
+ }
+ if len(report.Resolved)+len(report.Skipped) != 3 {
+ t.Fatalf("expected 3 dependencies, got %d",
len(report.Resolved)+len(report.Skipped))
+ }
+ }
+
+ // Library case: only runtime deps reachable from gemspec (1: rake)
+ {
+ tmp := t.TempDir()
+ if err := copyRuby("testdata/ruby/library", tmp); err != nil {
+ t.Fatal(err)
+ }
+ lock := filepath.Join(tmp, "Gemfile.lock")
+ cfg := &deps.ConfigDeps{Files: []string{lock}, Licenses:
[]*deps.ConfigDepLicense{
+ {Name: "rake", Version: "13.0.6", License: "MIT"},
+ }}
+ report := deps.Report{}
+ if err := resolver.Resolve(lock, cfg, &report); err != nil {
+ t.Fatal(err)
+ }
+ if len(report.Resolved)+len(report.Skipped) != 1 {
+ t.Fatalf("expected 1 dependency for library, got %d",
len(report.Resolved)+len(report.Skipped))
+ }
+ }
+}
diff --git a/pkg/deps/testdata/ruby/app/Gemfile.lock
b/pkg/deps/testdata/ruby/app/Gemfile.lock
new file mode 100644
index 0000000..7d6478b
--- /dev/null
+++ b/pkg/deps/testdata/ruby/app/Gemfile.lock
@@ -0,0 +1,17 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ rake (13.0.6)
+ rspec (3.10.0)
+ rspec-core (~> 3.10)
+ rspec-core (3.10.1)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ rake
+ rspec
+
+BUNDLED WITH
+ 2.4.10
diff --git a/pkg/deps/testdata/ruby/library/Gemfile.lock
b/pkg/deps/testdata/ruby/library/Gemfile.lock
new file mode 100644
index 0000000..7d6478b
--- /dev/null
+++ b/pkg/deps/testdata/ruby/library/Gemfile.lock
@@ -0,0 +1,17 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ rake (13.0.6)
+ rspec (3.10.0)
+ rspec-core (~> 3.10)
+ rspec-core (3.10.1)
+
+PLATFORMS
+ ruby
+
+DEPENDENCIES
+ rake
+ rspec
+
+BUNDLED WITH
+ 2.4.10
diff --git a/pkg/deps/testdata/ruby/library/sample.gemspec
b/pkg/deps/testdata/ruby/library/sample.gemspec
new file mode 100644
index 0000000..9ff595d
--- /dev/null
+++ b/pkg/deps/testdata/ruby/library/sample.gemspec
@@ -0,0 +1,28 @@
+# 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 = "sample"
+ spec.version = "0.1.0"
+ spec.summary = "Sample gem"
+ spec.description = "Sample"
+ spec.authors = ["Test"]
+ spec.files = []
+
+ spec.add_runtime_dependency 'rake', '>= 13.0'
+ spec.add_development_dependency 'rspec', '~> 3.10'
+end