Skip to content

Commit 5df3936

Browse files
authored
Merge pull request #12998 from dependabot/brrygrdn/dg-7658-establish-file-grapher
Structure Dependabot::DependencyGrapher as an ecosystem component with generic fallback
2 parents 219b0d4 + d93a6db commit 5df3936

File tree

9 files changed

+542
-108
lines changed

9 files changed

+542
-108
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "spec_helper"
5+
require "dependabot/bundler"
6+
require "dependabot/dependency_graphers"
7+
8+
# TODO: Implement a concrete Bundler class
9+
RSpec.describe "Dependabot::DependencyGraphers::Generic" do
10+
context "with a bundler project" do
11+
subject(:grapher) do
12+
Dependabot::DependencyGraphers.for_package_manager("bundler").new(
13+
dependency_files:,
14+
dependencies:
15+
)
16+
end
17+
18+
let(:parser) do
19+
Dependabot::FileParsers.for_package_manager("bundler").new(
20+
dependency_files:,
21+
repo_contents_path: nil,
22+
source: source,
23+
credentials: [],
24+
reject_external_code: false
25+
)
26+
end
27+
28+
let(:source) do
29+
Dependabot::Source.new(
30+
provider: "github",
31+
repo: "dependabot-fixtures/dependabot-test-ruby-package",
32+
directory: "/",
33+
branch: "main"
34+
)
35+
end
36+
37+
let(:dependencies) { parser.parse }
38+
39+
# NOTE: This documents existing behaviour where Gemfile PURLs do not include a resolved version
40+
#
41+
# Package URLs deal in resolved versions, so for a Gemfile only project we only have a range
42+
# which cannot currently be submitted to the Dependency Submission API.
43+
#
44+
# This is working as intended, but when we implement a concrete Bundler grapher we will want
45+
# to execute a `bundle install` to resolve the file at runtime so we can submit the resolved
46+
# dependencies.
47+
context "with a Gemfile only" do
48+
let(:gemfile) do
49+
bundler_project_dependency_file("subdependency", filename: "Gemfile")
50+
end
51+
52+
let(:dependency_files) do
53+
[gemfile]
54+
end
55+
56+
it "specifies the Gemfile as the relevant dependency file" do
57+
expect(grapher.relevant_dependency_file).to eql(gemfile)
58+
end
59+
60+
it "correctly serializes the resolved dependencies" do
61+
expect(grapher.resolved_dependencies.count).to be(1)
62+
63+
ibandit = grapher.resolved_dependencies["ibandit"]
64+
expect(ibandit[:package_url]).to eql("pkg:gem/ibandit")
65+
expect(ibandit[:relationship]).to eql("direct")
66+
expect(ibandit[:scope]).to eql("runtime")
67+
expect(ibandit[:dependencies]).to be_empty
68+
end
69+
end
70+
71+
context "with a Gemfile and Gemfile.lock" do
72+
let(:gemfile) do
73+
bundler_project_dependency_file("subdependency", filename: "Gemfile")
74+
end
75+
76+
let(:gemfile_lock) do
77+
bundler_project_dependency_file("subdependency", filename: "Gemfile.lock")
78+
end
79+
80+
let(:dependency_files) do
81+
[gemfile, gemfile_lock]
82+
end
83+
84+
it "specifies the Gemfile.lock as the relevant dependency file" do
85+
expect(grapher.relevant_dependency_file).to eql(gemfile_lock)
86+
end
87+
88+
it "correctly serializes the resolved dependencies" do
89+
resolved_dependencies = grapher.resolved_dependencies
90+
91+
expect(resolved_dependencies.count).to be(2)
92+
93+
expect(resolved_dependencies.keys).to eql(%w(ibandit i18n))
94+
95+
ibandit = resolved_dependencies["ibandit"]
96+
expect(ibandit[:package_url]).to eql("pkg:gem/[email protected]")
97+
expect(ibandit[:relationship]).to eql("direct")
98+
expect(ibandit[:scope]).to eql("runtime")
99+
expect(ibandit[:dependencies]).to be_empty # NYI: We don't set any subdependencies yet, this should contain i18n
100+
101+
i18n = resolved_dependencies["i18n"]
102+
expect(i18n[:package_url]).to eql("pkg:gem/[email protected]")
103+
expect(i18n[:relationship]).to eql("indirect")
104+
expect(i18n[:scope]).to eql("runtime")
105+
expect(i18n[:dependencies]).to be_empty
106+
end
107+
end
108+
end
109+
end
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# typed: strong
2+
# frozen_string_literal: true
3+
4+
require "sorbet-runtime"
5+
6+
require "dependabot/dependency_graphers/base"
7+
require "dependabot/dependency_graphers/generic"
8+
9+
module Dependabot
10+
module DependencyGraphers
11+
extend T::Sig
12+
13+
@graphers = T.let({}, T::Hash[String, T.class_of(Base)])
14+
15+
sig { params(package_manager: String).returns(T.class_of(Base)) }
16+
def self.for_package_manager(package_manager)
17+
grapher = @graphers[package_manager]
18+
return grapher if grapher
19+
20+
# If an ecosystem has not defined its own graphing strategy, then we use
21+
# a best-effort generic while we are rolling out graphing capabilities.
22+
#
23+
# This approach allows us to assess the quality of data from the ecosystem's
24+
# parser and triage the scope of work to implement the non-generic class.
25+
Generic
26+
end
27+
28+
sig { params(package_manager: String, grapher: T.class_of(Base)).void }
29+
def self.register(package_manager, grapher)
30+
@graphers[package_manager] = grapher
31+
end
32+
end
33+
end
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Dependency Graphers
2+
3+
Dependency graphers are used to convert a set of parsed dependencies into a data structure we can use to output the dependency graph of a project in a generic data structure based on GitHub's [Dependency submission API](https://docs.github.com/en/rest/dependency-graph/dependency-submission).
4+
5+
We will expect each language Dependabot supports to implement a `Dependabot::DependencyGraphers` class in future, but for now any modules that do not implement a specific class fail over to a 'best effort' generic implementation that works in most cases.
6+
7+
## Public API
8+
9+
Each `Dependabot::DependencyGraphers` class implements the following methods:
10+
11+
| Method | Description |
12+
|-----------------------------|-----------------------------------------------------------------------------------------------|
13+
| `.relevant_dependency_file` | Checks the list of `Dependabot::DependencyFile` objects assigned and determines which one the dependency list should be reported against. In most languages this will be the lockfile, if present, and the manifest otherwise. |
14+
| `.resolved_dependencies` | Processes the assigned `Dependabot::Dependency` objects into an informational hash |
15+
16+
An example of a `.resolved_dependencies` hash for a Bundler project:
17+
18+
```ruby
19+
"addressable": {
20+
"package_url": "pkg:gem/[email protected]",
21+
"relationship": "indirect",
22+
"scope": "runtime",
23+
"dependencies": [],
24+
"metadata": {}
25+
},
26+
"ast": {
27+
"package_url": "pkg:gem/[email protected]",
28+
"relationship": "indirect",
29+
"scope": "runtime",
30+
"dependencies": [],
31+
"metadata": {}
32+
},
33+
"aws-eventstream": {
34+
"package_url": "pkg:gem/[email protected]",
35+
"relationship": "indirect",
36+
"scope": "runtime",
37+
"dependencies": [],
38+
"metadata": {}
39+
}
40+
```
41+
42+
## Writing a file fetcher for a new language
43+
44+
All new file fetchers should inherit from `Dependabot::DependencyGraphers::Base` and
45+
implement the following methods:
46+
47+
| Method | Description |
48+
|----------------------------------|-----------------------------------------------------------------------------------------------|
49+
| `.relevant_dependency_file` | See Public API section. |
50+
| `.fetch_subdependencies` | Private method to fetch a list of package names, or PURLs, that are subdependencies of a given `Dependabot::Dependency`. It is expected that some languages will need to perform additional native commands to obtain this data. |
51+
| `.purl_pkg_for` | Private method to map the given `Dependabot::Dependency` to the correct [Package-URL type](https://github.com/package-url/purl-spec/blob/main/PURL-TYPES.rst) for the package manager involved. |
52+
53+
> [!WARNING]
54+
> While PURLs are preferred in all languages for `.fetch_subdependencies`, for languages where multiple versions of a single dependency are permitted they _must_ be provided to be precise.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# typed: strong
2+
# frozen_string_literal: true
3+
4+
require "sorbet-runtime"
5+
6+
module Dependabot
7+
module DependencyGraphers
8+
class Base
9+
extend T::Sig
10+
extend T::Helpers
11+
12+
abstract!
13+
14+
# TODO(brrygrdn): Inject the Dependency parser instead of pre-parsed `dependencies`
15+
#
16+
# Semantically it makes sense for the grapher to wrap the parser as a higher order function, but we already know
17+
# that some package managers will require extra native commands before, after or during the parse - in extreme
18+
# cases it may make sense to use an alternative parser that is more optimal.
19+
#
20+
# By injecting the parser, this allows the ecosystem to encapsulate the package manager specifics without the
21+
# executor needing to manage parser modes / feature flags.
22+
sig do
23+
params(
24+
dependency_files: T::Array[Dependabot::DependencyFile],
25+
dependencies: T::Array[Dependabot::Dependency]
26+
).void
27+
end
28+
def initialize(dependency_files:, dependencies:)
29+
@dependency_files = dependency_files
30+
@dependencies = dependencies
31+
end
32+
33+
# Each grapher must implement a heuristic to determine which dependency file should be used as the owner
34+
# of the resolved_dependencies.
35+
#
36+
# Conventionally, this is the lockfile for the file set but some parses may only include the manifest
37+
# so this method should take into account the correct priority based on which files were parsed.
38+
sig { abstract.returns(Dependabot::DependencyFile) }
39+
def relevant_dependency_file; end
40+
41+
sig { returns(T::Hash[Symbol, T.untyped]) }
42+
def resolved_dependencies
43+
@dependencies.each_with_object({}) do |dep, resolved|
44+
resolved[dep.name] = {
45+
package_url: build_purl(dep),
46+
relationship: relationship_for(dep),
47+
scope: scope_for(dep),
48+
dependencies: fetch_subdependencies(dep),
49+
metadata: {}
50+
}
51+
end
52+
end
53+
54+
private
55+
56+
# Each grapher is expected to implement a method to look up the parents of a given dependency.
57+
#
58+
# The strategy that should be used is highly dependent on the ecosystem, in some cases the parser
59+
# may be able to set this information in the dependency.metadata collection, in others the grapher
60+
# will need to run additional native commands.
61+
sig { abstract.params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
62+
def fetch_subdependencies(dependency); end
63+
64+
# Each grapher is expected to implement a method to map the various package managers it supports to
65+
# the correct Package-URL type, see:
66+
# https://github.com/package-url/purl-spec/blob/main/PURL-TYPES.rst
67+
sig { abstract.params(package_manager: String).returns(String) }
68+
def purl_pkg_for(package_manager); end
69+
70+
# Generate a purl for the provided Dependency object
71+
sig { params(dependency: Dependabot::Dependency).returns(String) }
72+
def build_purl(dependency)
73+
"pkg:#{purl_pkg_for(dependency.package_manager)}/#{dependency.name}@#{dependency.version}".chomp("@")
74+
end
75+
76+
sig { params(dep: Dependabot::Dependency).returns(String) }
77+
def relationship_for(dep)
78+
if dep.top_level?
79+
"direct"
80+
else
81+
"indirect"
82+
end
83+
end
84+
85+
sig { params(dependency: Dependabot::Dependency).returns(String) }
86+
def scope_for(dependency)
87+
if dependency.production?
88+
"runtime"
89+
else
90+
"development"
91+
end
92+
end
93+
end
94+
end
95+
end
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "sorbet-runtime"
5+
6+
require "dependabot/dependency_graphers/base"
7+
8+
module Dependabot
9+
module DependencyGraphers
10+
class Generic < Base
11+
extend T::Sig
12+
extend T::Helpers
13+
14+
# Our generic strategy is to use the right-most file in the dependency file list on the
15+
# assumption that this is normally the lockfile.
16+
#
17+
# This isn't a durable strategy but it's good enough to allow most ecosystems to 'just work'
18+
# as we roll out ecosystem-specific graphers.
19+
sig { override.returns(Dependabot::DependencyFile) }
20+
def relevant_dependency_file
21+
T.must(filtered_dependency_files.last)
22+
end
23+
24+
private
25+
26+
sig { returns(T::Array[Dependabot::DependencyFile]) }
27+
def filtered_dependency_files
28+
@dependency_files.reject { |f| f.support_file? || f.vendored_file? }
29+
end
30+
31+
# Our generic strategy is to check if the parser has attached a `depends_on` key to the Dependency's
32+
# metadata, but in most cases this will be empty.
33+
sig { override.params(dependency: Dependabot::Dependency).returns(T::Array[String]) }
34+
def fetch_subdependencies(dependency)
35+
dependency.metadata.fetch(:depends_on, [])
36+
end
37+
38+
# TODO: Delegate this to ecosystem-specific base classes
39+
sig { override.params(package_manager: String).returns(String) }
40+
def purl_pkg_for(package_manager)
41+
case package_manager
42+
when "bundler"
43+
"gem"
44+
when "npm_and_yarn", "bun"
45+
"npm"
46+
when "maven", "gradle"
47+
"maven"
48+
when "pip", "uv"
49+
"pypi"
50+
when "cargo"
51+
"cargo"
52+
when "hex"
53+
"hex"
54+
when "composer"
55+
"composer"
56+
when "nuget"
57+
"nuget"
58+
when "go_modules"
59+
"golang"
60+
when "docker"
61+
"docker"
62+
when "github_actions"
63+
"github"
64+
when "terraform"
65+
"terraform"
66+
when "pub"
67+
"pub"
68+
when "elm"
69+
"elm"
70+
else
71+
"generic"
72+
end
73+
end
74+
end
75+
end
76+
end

go_modules/lib/dependabot/go_modules.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
# These all need to be required so the various classes can be registered in a
55
# lookup table of package manager names to concrete classes.
6+
require "dependabot/go_modules/dependency_grapher"
67
require "dependabot/go_modules/file_fetcher"
78
require "dependabot/go_modules/file_parser"
89
require "dependabot/go_modules/update_checker"

0 commit comments

Comments
 (0)