dependabot-apm 0.400.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "dependabot/metadata_finders"
7
+ require "dependabot/metadata_finders/base"
8
+ require "dependabot/source"
9
+ require "dependabot/apm/package_specifier"
10
+
11
+ module Dependabot
12
+ module Apm
13
+ class MetadataFinder < Dependabot::MetadataFinders::Base
14
+ extend T::Sig
15
+
16
+ # Hosts whose canonical git provider lets us rebuild the Source from the
17
+ # full repository path. Dependabot::Source.from_url caps the repo at three
18
+ # path segments and is not end-anchored, so a deep GitLab namespace such as
19
+ # `group/subgroup/team/project` would be truncated to `group/subgroup/team`
20
+ # and resolve to the wrong source/changelog. APM already knows the complete
21
+ # path via PackageSpecifier, so we bypass that regex for these hosts.
22
+ PROVIDER_BY_HOST = T.let(
23
+ {
24
+ "github.com" => "github",
25
+ "gitlab.com" => "gitlab",
26
+ "bitbucket.org" => "bitbucket"
27
+ }.freeze,
28
+ T::Hash[String, String]
29
+ )
30
+
31
+ # The API path each provider serves under its own authority. A dependency
32
+ # may pin a custom port (`github.com:8443`), which is a distinct endpoint
33
+ # from the public SaaS host: it is a self-hosted-style install whose API is
34
+ # served from the same authority (mirroring Dependabot::Source's GitHub
35
+ # Enterprise `…/api/v3` handling), not from the public `api.github.com`.
36
+ # Used only when a port is present; portless canonical hosts keep the
37
+ # provider's public defaults.
38
+ API_PATH_BY_PROVIDER = T.let(
39
+ {
40
+ "github" => "api/v3",
41
+ "gitlab" => "api/v4",
42
+ "bitbucket" => "2.0"
43
+ }.freeze,
44
+ T::Hash[String, String]
45
+ )
46
+
47
+ private
48
+
49
+ sig { override.returns(T.nilable(Dependabot::Source)) }
50
+ def look_up_source
51
+ url = dependency.requirements.first&.source_string("url")
52
+ return unless url
53
+
54
+ source_from_full_path(url) || Source.from_url(url)
55
+ end
56
+
57
+ # Builds the Source directly from the full repository path so nested
58
+ # namespaces survive, falling back (via the caller) to Source.from_url for
59
+ # hosts we cannot resolve a provider for (e.g. self-hosted installs).
60
+ sig { params(url: String).returns(T.nilable(Dependabot::Source)) }
61
+ def source_from_full_path(url)
62
+ spec = Dependabot::Apm::PackageSpecifier.parse(url)
63
+ return unless spec
64
+
65
+ # Classify by the hostname alone: a custom port is transport, not a
66
+ # different provider, so `github.com:8443` is still GitHub.
67
+ hostname = Dependabot::Apm::PackageSpecifier.hostname_without_port(spec.host)
68
+ provider = PROVIDER_BY_HOST[hostname]
69
+ return if provider.nil? || spec.repo.empty?
70
+
71
+ repo = "#{spec.owner}/#{spec.repo}"
72
+ # A portless canonical host uses the provider's public defaults.
73
+ return Dependabot::Source.new(provider: provider, repo: repo) if spec.host == hostname
74
+
75
+ # A custom port is a distinct, self-hosted-style endpoint: preserve the
76
+ # full authority and its matching API so source and changelog links
77
+ # target it. Passing the URL to Source.from_url instead would misread the
78
+ # port as part of the repo path (`github.com:8443/org/repo` -> `8443/org`).
79
+ Dependabot::Source.new(
80
+ provider: provider,
81
+ repo: repo,
82
+ hostname: spec.host,
83
+ api_endpoint: "https://#{spec.host}/#{API_PATH_BY_PROVIDER.fetch(provider)}"
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ Dependabot::MetadataFinders
91
+ .register("apm", Dependabot::Apm::MetadataFinder)
@@ -0,0 +1,75 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "dependabot/ecosystem"
6
+ require "dependabot/version"
7
+
8
+ module Dependabot
9
+ module Apm
10
+ ECOSYSTEM = "apm"
11
+ PACKAGE_MANAGER = "apm"
12
+
13
+ # The default version reported when the resolved apm CLI version cannot be
14
+ # determined from the lockfile (`apm_version`).
15
+ DEFAULT_PACKAGE_MANAGER_VERSION = "0.0.0"
16
+
17
+ SUPPORTED_APM_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version])
18
+ DEPRECATED_APM_VERSIONS = T.let([].freeze, T::Array[Dependabot::Version])
19
+
20
+ class PackageManager < Dependabot::Ecosystem::VersionManager
21
+ extend T::Sig
22
+
23
+ # Matches the top-level `apm_version` key the lockfile records for the apm
24
+ # CLI that wrote it. The value may be plain or quoted, so optional quotes
25
+ # are stripped. Nested `version:` keys under `dependencies:` are indented
26
+ # and so never match this line-anchored pattern.
27
+ LOCKFILE_VERSION_REGEX = /^apm_version:\s*['"]?(?<version>[^'"\s]+)['"]?\s*$/
28
+
29
+ sig { params(raw_version: String).void }
30
+ def initialize(raw_version)
31
+ super(
32
+ name: PACKAGE_MANAGER,
33
+ version: PackageManager.parse_version(raw_version),
34
+ deprecated_versions: DEPRECATED_APM_VERSIONS,
35
+ supported_versions: SUPPORTED_APM_VERSIONS
36
+ )
37
+ end
38
+
39
+ # The apm CLI version recorded in the lockfile (`apm_version`), or nil when
40
+ # there is no lockfile or it does not record one. Read straight from the
41
+ # raw content rather than by parsing the YAML: the lockfile is a support
42
+ # file we never rewrite, and a malformed one should degrade to the default
43
+ # version rather than raise while parsing the project.
44
+ sig { params(lockfile_content: T.nilable(String)).returns(T.nilable(String)) }
45
+ def self.version_from_lockfile(lockfile_content)
46
+ lockfile_content&.match(LOCKFILE_VERSION_REGEX)&.[](:version)
47
+ end
48
+
49
+ # The apm CLI version recorded in the lockfile (`apm_version`) is written
50
+ # by apm from its Python (PEP 440) distribution metadata, and the lockfile
51
+ # schema permits an arbitrary string, so it is not necessarily strict
52
+ # SemVer (e.g. `0.32.0rc1`, `0.32.0.dev3`). Parse it with the permissive
53
+ # base version rather than the strict Apm::Version — which is meant for
54
+ # dependency git refs and would raise ArgumentError here — falling back to
55
+ # the default when the string cannot be represented at all, so an unusual
56
+ # CLI version never prevents parsing the project.
57
+ sig { params(raw_version: String).returns(Dependabot::Version) }
58
+ def self.parse_version(raw_version)
59
+ return Dependabot::Version.new(raw_version) if Dependabot::Version.correct?(raw_version)
60
+
61
+ Dependabot::Version.new(DEFAULT_PACKAGE_MANAGER_VERSION)
62
+ end
63
+
64
+ sig { returns(T::Boolean) }
65
+ def deprecated?
66
+ false
67
+ end
68
+
69
+ sig { returns(T::Boolean) }
70
+ def unsupported?
71
+ false
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,347 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ module Dependabot
7
+ module Apm
8
+ # Parses a single `dependencies.apm` entry from an `apm.yml` manifest into its
9
+ # git coordinates. APM supports several equivalent forms for the same entry:
10
+ #
11
+ # owner/repo GitHub shorthand, default branch
12
+ # owner/repo#v1.0.0 Pinned to a tag, branch or SHA
13
+ # gitlab.com/acme/repo#v2.0 FQDN shorthand (any git host)
14
+ # gitlab.com/group/subgroup/repo#v2.0 Nested subgroup repo (non-GitHub)
15
+ # owner/repo/skills/review Virtual subdirectory of a repo
16
+ # owner/repo/prompts/x.prompt.md#v1.0.0 Virtual file within a repo
17
+ # https://gitlab.com/acme/repo.git Explicit HTTPS git URL
18
+ # git@gitlab.com:acme/repo.git SSH SCP-style URL
19
+ # ssh://git@gitlab.com/acme/repo.git SSH URI-style URL
20
+ #
21
+ # HTTPS and SSH clone URLs both resolve to an `https://host[:port]/owner/repo`
22
+ # remote, since Dependabot enumerates tags over HTTPS with a token. An
23
+ # `https://` source keeps its authority, including a non-default port
24
+ # (`github.com:8443` is a distinct endpoint); the implicit `:443` is
25
+ # normalised away so it collapses to the same endpoint as a portless
26
+ # declaration. An `ssh://` (or `git@host:`
27
+ # SCP) source reaches the same repositories over HTTPS, so it resolves there
28
+ # and its SSH port (e.g. `:2222`) is dropped, as it is not an HTTPS port.
29
+ # Host classification (GitHub-family, Azure DevOps) always ignores the port.
30
+ # `http://` and `git://` URLs name a different endpoint than HTTPS and are
31
+ # not silently rewritten; they are out of scope for v1 and resolve to `nil`.
32
+ #
33
+ # Virtual package paths (`owner/repo/skills/review`) are a GitHub-family
34
+ # shorthand, since GitHub repositories are always `owner/repo`. That covers
35
+ # github.com and GitHub Enterprise Cloud data-residency hosts (`*.ghe.com`),
36
+ # which APM also resolves as GitHub. On other hosts (e.g. GitLab) the whole
37
+ # path is treated as the repository so nested subgroups resolve to the
38
+ # correct remote; virtual packages there are out of scope for v1, as is
39
+ # self-hosted GHES on an arbitrary hostname that cannot be recognised from
40
+ # the host alone.
41
+ #
42
+ # Azure DevOps hosts (`dev.azure.com`, `ssh.dev.azure.com` and legacy
43
+ # `*.visualstudio.com`) expose repositories at `org/project/_git/repo`, a
44
+ # structure this generic `owner/repo` builder cannot construct, so their
45
+ # entries are out of scope for v1 and resolve to `nil`.
46
+ #
47
+ # Local path entries (`./pkg`, `../pkg`, `/pkg`, `~/pkg`, and their Windows
48
+ # `.\`/`..\`/`~\` forms) are not versioned by a remote git host and resolve
49
+ # to `nil`, as do entries we cannot confidently parse.
50
+ class PackageSpecifier
51
+ extend T::Sig
52
+
53
+ DEFAULT_HOST = "github.com"
54
+
55
+ # Azure DevOps Services hosts. Their repositories live at
56
+ # `org/project/_git/repo`, a structure this generic owner/repo builder
57
+ # cannot express, so ADO entries are out of scope for v1 (see README) and
58
+ # are skipped rather than resolved to a wrong remote. Legacy
59
+ # per-organisation hosts match the `.visualstudio.com` suffix separately.
60
+ AZURE_DEVOPS_HOSTS = %w(dev.azure.com ssh.dev.azure.com).freeze
61
+
62
+ # SCP-style SSH shorthand `user@host:path`. APM accepts any valid SSH
63
+ # username here (not only `git@`), so match any user that has no `:` or
64
+ # `/` (either would signal a URL scheme or path rather than an SCP user).
65
+ # The user is discarded — only the host and path determine the git
66
+ # coordinates, just as URL_STYLE drops its `user@` info.
67
+ SCP_STYLE = %r{\A(?<user>[^@:/\s]+)@(?<host>[^:/\s]+):(?<path>.+)\z}
68
+ # Matches https/git/ssh URIs, discarding any `user@` info (e.g. the
69
+ # `git@` in an `ssh://git@host/owner/repo` URL) so only the host remains.
70
+ # URI schemes are case-insensitive (RFC 3986), so the scheme alternation
71
+ # is matched case-insensitively and normalised (downcased) before dispatch;
72
+ # e.g. `HTTPS://host/owner/repo` is recognised rather than mis-parsed as a
73
+ # default-host shorthand.
74
+ URL_STYLE = %r{\A(?<scheme>(?i:https?|git|ssh))://(?:[^@/]+@)?(?<host>[^/]+)/(?<path>.+)\z}
75
+
76
+ sig { returns(String) }
77
+ attr_reader :host
78
+
79
+ sig { returns(String) }
80
+ attr_reader :owner
81
+
82
+ sig { returns(String) }
83
+ attr_reader :repo
84
+
85
+ sig { returns(T.nilable(String)) }
86
+ attr_reader :sub_path
87
+
88
+ sig { returns(T.nilable(String)) }
89
+ attr_reader :ref
90
+
91
+ sig { params(raw: Object, default_host: String).returns(T.nilable(Dependabot::Apm::PackageSpecifier)) }
92
+ def self.parse(raw, default_host: DEFAULT_HOST)
93
+ return nil unless raw.is_a?(String)
94
+
95
+ entry = raw.strip
96
+ return nil if entry.empty? || local_path?(entry)
97
+
98
+ spec, _, ref = entry.partition("#")
99
+ host, path = split_host_and_path(spec, default_host)
100
+ return nil unless path
101
+
102
+ build(host: host, path: path, ref: ref, default_host: default_host)
103
+ end
104
+
105
+ # True when `raw` is the string shorthand form (`[host/]owner/repo…`)
106
+ # rather than an explicit git reference (`https://`, `http://`, `ssh://git@`,
107
+ # an SCP `git@host:path`, or any `.git`-suffixed ref). When an apm manifest
108
+ # configures a default registry, APM routes shorthand entries through it
109
+ # instead of Git, so the parser skips them (registry dependencies are out
110
+ # of scope for v1). This mirrors APM's `_is_explicit_git_form`, which
111
+ # routes URL, SCP and `.git` forms to Git even when a default registry is
112
+ # configured -- they are the escape hatch and are never registry-routed.
113
+ sig { params(raw: Object).returns(T::Boolean) }
114
+ def self.shorthand?(raw)
115
+ return false unless raw.is_a?(String)
116
+
117
+ spec = raw.strip.partition("#").first.to_s
118
+ return false if spec.empty? || local_path?(spec)
119
+
120
+ # A `.git` suffix marks an explicit git reference (as APM's
121
+ # `_is_explicit_git_form` treats it), so it is a Git escape hatch rather
122
+ # than a registry-routed shorthand even without a transport scheme.
123
+ return false if spec.downcase.end_with?(".git")
124
+
125
+ !spec.match?(SCP_STYLE) && !spec.match?(URL_STYLE)
126
+ end
127
+
128
+ sig do
129
+ params(host: String, path: String, ref: String, default_host: String)
130
+ .returns(T.nilable(Dependabot::Apm::PackageSpecifier))
131
+ end
132
+ def self.build(host:, path:, ref:, default_host: DEFAULT_HOST)
133
+ # DNS hostnames are case-insensitive, so canonicalise to lowercase once
134
+ # here; repository splitting, naming and credential-host matching all key
135
+ # off `host` and must agree on e.g. `GitHub.com` == `github.com`.
136
+ host = host.downcase
137
+ # Azure DevOps clone URLs are `org/project/_git/repo`, which this generic
138
+ # owner/repo builder cannot construct, so ADO shorthands and URLs are not
139
+ # versioned in v1 (see README). Skip them rather than emit a wrong remote
140
+ # that GitCommitChecker would query as a non-repository endpoint.
141
+ return nil if azure_devops_host?(host)
142
+
143
+ segments = path.delete_suffix(".git").split("/").reject(&:empty?)
144
+ owner = segments[0]
145
+ return nil if owner.nil? || segments[1].nil?
146
+
147
+ repo, sub_path = repository_and_sub_path(host, segments)
148
+
149
+ # GitHub-family owner/repo paths are case-insensitive, so canonicalise
150
+ # them to lowercase to give each repository a single stable identity.
151
+ # Other hosts (e.g. GitLab) are case-sensitive and MUST preserve casing,
152
+ # as must virtual sub paths (they address entries inside the repo tree).
153
+ if github_family?(host)
154
+ owner = owner.downcase
155
+ repo = repo.downcase
156
+ end
157
+
158
+ new(
159
+ host: host,
160
+ owner: owner,
161
+ repo: repo,
162
+ sub_path: sub_path.empty? ? nil : sub_path,
163
+ ref: ref.empty? ? nil : ref,
164
+ default_host: default_host.downcase
165
+ )
166
+ end
167
+
168
+ # Splits the path segments (after `owner`) into a repository path and an
169
+ # optional virtual sub path. GitHub-family hosts (github.com and GitHub
170
+ # Enterprise Cloud `*.ghe.com`) always expose repositories as exactly
171
+ # `owner/repo`, so any deeper segments are an APM virtual package path.
172
+ # Other hosts (e.g. GitLab) allow repositories nested at arbitrary subgroup
173
+ # depth, and APM resolves the repository/virtual boundary host-specifically
174
+ # rather than from the path alone; to avoid querying a shallower, wrong
175
+ # remote we keep the whole path as the repository there. Virtual packages
176
+ # outside the GitHub family are therefore out of scope for v1.
177
+ sig { params(host: String, segments: T::Array[String]).returns([String, String]) }
178
+ def self.repository_and_sub_path(host, segments)
179
+ if github_family?(host)
180
+ [T.must(segments[1]), (segments[2..] || []).join("/")]
181
+ else
182
+ [(segments[1..] || []).join("/"), ""]
183
+ end
184
+ end
185
+
186
+ # GitHub-family hosts share the strict `owner/repo` boundary (any deeper
187
+ # segments are an APM virtual package path) and case-insensitive owner and
188
+ # repo names. That is github.com plus GitHub Enterprise Cloud
189
+ # data-residency hosts (`*.ghe.com`), which APM resolves as GitHub too.
190
+ # Self-hosted GHES uses arbitrary hostnames that cannot be recognised from
191
+ # the host alone, so it is not detected here -- a follow-up for once host
192
+ # configuration is threaded through the parser.
193
+ sig { params(host: String).returns(T::Boolean) }
194
+ def self.github_family?(host)
195
+ hostname = hostname_without_port(host)
196
+ hostname == DEFAULT_HOST || hostname.end_with?(".ghe.com")
197
+ end
198
+
199
+ # True for Azure DevOps Services hosts: dev.azure.com, its SSH alias, and
200
+ # legacy per-organisation `*.visualstudio.com` hosts. APM special-cases
201
+ # these with `org/project/_git/repo` clone URLs; v1 does not build those,
202
+ # so such entries are skipped (see README) rather than resolved to a wrong
203
+ # remote.
204
+ sig { params(host: String).returns(T::Boolean) }
205
+ def self.azure_devops_host?(host)
206
+ hostname = hostname_without_port(host)
207
+ AZURE_DEVOPS_HOSTS.include?(hostname) || hostname.end_with?(".visualstudio.com")
208
+ end
209
+
210
+ # The hostname with any `:port` suffix removed. Host-family classification
211
+ # keys off the hostname alone: the port is part of the authority that
212
+ # `git_url` and `name` keep, but it must not change which family a URL
213
+ # belongs to (`github.com:8443` is still GitHub, `foo.ghe.com:8443` still
214
+ # GHE Cloud). Only a trailing `:<digits>` is stripped, so bracketed IPv6
215
+ # authorities are left intact.
216
+ sig { params(host: String).returns(String) }
217
+ def self.hostname_without_port(host)
218
+ host.sub(/:\d+\z/, "")
219
+ end
220
+
221
+ # The authority with a default HTTPS port removed. APM treats a
222
+ # non-default port as part of a dependency's identity but normalises the
223
+ # implicit `:443` away (its `_split_shorthand_host_port` maps port 443 to
224
+ # "no port"), so `github.com:443` and `github.com` are the same endpoint
225
+ # while `github.com:8443` stays distinct. Applied when parsing an HTTPS
226
+ # URL so `git_url` and `name` share one canonical authority; only a
227
+ # trailing `:<digits>` is considered, leaving bracketed IPv6 hosts intact.
228
+ sig { params(host: String).returns(String) }
229
+ def self.normalize_https_port(host)
230
+ match = host.match(/\A(?<hostname>.+):(?<port>\d+)\z/)
231
+ return host unless match
232
+
233
+ T.must(match[:port]).to_i == 443 ? T.must(match[:hostname]) : host
234
+ end
235
+
236
+ sig { params(entry: String).returns(T::Boolean) }
237
+ def self.local_path?(entry)
238
+ entry.start_with?("./", "../", "/", "~/", ".\\", "..\\", "~\\") ||
239
+ entry == "." || entry == "~"
240
+ end
241
+
242
+ sig { params(spec: String, default_host: String).returns([String, T.nilable(String)]) }
243
+ def self.split_host_and_path(spec, default_host)
244
+ if (m = spec.match(SCP_STYLE))
245
+ return [T.must(m[:host]), m[:path]]
246
+ end
247
+
248
+ if (m = spec.match(URL_STYLE))
249
+ host = T.must(m[:host])
250
+ case T.must(m[:scheme]).downcase
251
+ when "https"
252
+ # HTTPS is the transport we query, so keep the authority, including
253
+ # a non-default port: `github.com:8443` is a distinct endpoint from
254
+ # `github.com` and must stay that way. The implicit `:443` is
255
+ # normalised off so a `github.com:443` declaration collapses to the
256
+ # same endpoint and identity as a portless one, matching APM's
257
+ # default-port handling.
258
+ return [normalize_https_port(host), m[:path]]
259
+ when "ssh"
260
+ # SSH reaches the same repositories as HTTPS on that host, and
261
+ # Dependabot enumerates tags over HTTPS with a token, so resolve
262
+ # ssh:// URIs over HTTPS. The SSH port (e.g. the `:2222` on
263
+ # `ssh://git@host:2222/...`) is not an HTTPS port, so drop it.
264
+ return [host.sub(/:\d+\z/, ""), m[:path]]
265
+ else
266
+ # http:// and git:// name a different endpoint than HTTPS (a
267
+ # distinct port, and for http an unencrypted service). We only query
268
+ # over HTTPS, so rather than silently rewrite them to a possibly
269
+ # wrong remote we treat them as out of scope for v1 (see README);
270
+ # returning a nil path makes `parse` yield nil.
271
+ return [host, nil]
272
+ end
273
+ end
274
+
275
+ first_segment = spec.split("/").first.to_s
276
+ # A dot in the first path segment marks an FQDN host (GitHub owners never
277
+ # contain a dot), e.g. `gitlab.com/acme/repo`. Normalise the HTTPS port
278
+ # the same way as the explicit-URL branch so `github.com:443/org/repo`
279
+ # shorthand collapses to the same authority and identity as the portless
280
+ # and explicit-URL forms (a non-default port is kept as a distinct
281
+ # endpoint).
282
+ if first_segment.include?(".")
283
+ [normalize_https_port(first_segment), spec.split("/")[1..].to_a.join("/")]
284
+ else
285
+ [default_host, spec]
286
+ end
287
+ end
288
+
289
+ sig do
290
+ params(
291
+ host: String,
292
+ owner: String,
293
+ repo: String,
294
+ sub_path: T.nilable(String),
295
+ ref: T.nilable(String),
296
+ default_host: String
297
+ ).void
298
+ end
299
+ def initialize(host:, owner:, repo:, sub_path: nil, ref: nil, default_host: DEFAULT_HOST)
300
+ @host = host
301
+ @owner = owner
302
+ @repo = repo
303
+ @sub_path = sub_path
304
+ @ref = ref
305
+ @default_host = default_host
306
+ end
307
+
308
+ sig { returns(String) }
309
+ def git_url
310
+ "https://#{host}/#{owner}/#{repo}"
311
+ end
312
+
313
+ # The dependency name shown to users, which is also APM's dedup/lock
314
+ # identity. Repositories on the manifest's default host -- the configured
315
+ # `default_host`, or github.com when unset -- keep the familiar
316
+ # `owner/repo` shorthand, matching how APM keys the dependency;
317
+ # repositories on any other host are namespaced by host to stay unique.
318
+ # Comparing against the effective default (rather than a hard-coded
319
+ # github.com) means a manifest-selected `default_host` is stripped too, so
320
+ # dependency-name ignore rules and deduplication use the same identity APM
321
+ # does. A non-default port is part of the identity, matching APM's
322
+ # get_identity (`git.example.com:8443/org/repo` and
323
+ # `git.example.com:9443/org/repo` stay distinct dependencies rather than
324
+ # collapse into one entry that DependencySet would merge -- which, holding
325
+ # two different clone URLs, then raises `Multiple sources!`). The default
326
+ # `:443` is normalised off at parse time, so `git_url` and `name` share
327
+ # one authority and a `:443` declaration dedups with a portless one. A
328
+ # virtual package (sub path) is namespaced by that path too: APM keys
329
+ # virtual packages by repository plus path, so `org/mono/skills/review`
330
+ # and `org/mono/skills/security` must remain distinct dependencies rather
331
+ # than collapse into one `org/mono` entry that DependencySet would
332
+ # deduplicate.
333
+ sig { returns(String) }
334
+ def name
335
+ identity_host = self.class.hostname_without_port(host)
336
+ # `host` carries only a non-default port here (the default :443 is
337
+ # normalised off when parsing the URL), so it is the identity authority:
338
+ # on the default host with no port the bare `owner/repo` shorthand is
339
+ # used, otherwise the port-bearing authority is prefixed.
340
+ on_default_host = identity_host == @default_host && host == identity_host
341
+ repo_name = on_default_host ? "#{owner}/#{repo}" : "#{host}/#{owner}/#{repo}"
342
+ virtual_path = sub_path
343
+ virtual_path ? "#{repo_name}/#{virtual_path}" : repo_name
344
+ end
345
+ end
346
+ end
347
+ end
@@ -0,0 +1,68 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+
6
+ require "dependabot/apm/version"
7
+ require "dependabot/requirement"
8
+ require "dependabot/utils"
9
+
10
+ module Dependabot
11
+ module Apm
12
+ class Requirement < Dependabot::Requirement
13
+ extend T::Sig
14
+
15
+ # Requirement bounds are matched with SemVer 2.0.0 precedence, to stay
16
+ # consistent with Apm::Version. Left to Gem::Requirement, a prerelease
17
+ # bound such as `1.0.0-alpha.beta` is rewritten to the RubyGems form
18
+ # `1.0.0.pre.alpha.beta` and compared with RubyGems precedence, which
19
+ # orders prerelease identifiers the opposite way to SemVer (so a range
20
+ # like `< 1.0.0-alpha.beta` would wrongly exclude `1.0.0-alpha.1`).
21
+ quoted = OPS.keys.map { |k| Regexp.quote(k) }.join("|")
22
+ # Gem::Version::VERSION_PATTERN covers the release and `-prerelease`
23
+ # portions; the trailing group additionally accepts SemVer `+build`
24
+ # metadata so bounds like `= 1.0.0+build.5` are recognised too.
25
+ version_pattern = "#{Gem::Version::VERSION_PATTERN}(?:\\+[0-9A-Za-z][0-9A-Za-z.-]*)?"
26
+
27
+ PATTERN_RAW = T.let("\\s*(#{quoted})?\\s*(#{version_pattern})\\s*".freeze, String)
28
+ PATTERN = /\A#{PATTERN_RAW}\z/
29
+
30
+ # Builds each requirement bound from an Apm::Version so prerelease
31
+ # ordering follows SemVer. Operands that are not strict SemVer (e.g. the
32
+ # partial `>= 1.0` or `>= 0`) fall back to RubyGems parsing, which does
33
+ # not have the prerelease-ordering problem.
34
+ sig { override.params(obj: T.any(Gem::Version, String)).returns([String, Gem::Version]) }
35
+ def self.parse(obj)
36
+ if obj.is_a?(String) && (matches = PATTERN.match(obj)) && Apm::Version.correct?(T.must(matches[2]))
37
+ return [matches[1] || "=", Apm::Version.new(T.must(matches[2]))]
38
+ end
39
+
40
+ super
41
+ end
42
+
43
+ # For consistency with other languages, we define a requirements array.
44
+ # APM doesn't have an `OR` separator for requirements, so it always
45
+ # contains a single element.
46
+ sig { override.params(requirement_string: T.nilable(String)).returns(T::Array[Requirement]) }
47
+ def self.requirements_array(requirement_string)
48
+ [new(requirement_string)]
49
+ end
50
+
51
+ # Patches Gem::Requirement to make it accept requirement strings like
52
+ # "~> 4.2.5, >= 4.2.5.1" without first needing to split them.
53
+ sig { params(requirements: T.any(T.nilable(String), T::Array[T.nilable(String)])).void }
54
+ def initialize(*requirements)
55
+ requirements = requirements.flatten.flat_map do |req_string|
56
+ req_string&.split(",")&.map(&:strip)
57
+ end
58
+
59
+ super(requirements)
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ Dependabot::Utils.register_requirement_class(
66
+ "apm",
67
+ Dependabot::Apm::Requirement
68
+ )