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.
- checksums.yaml +7 -0
- data/lib/dependabot/apm/file_fetcher.rb +95 -0
- data/lib/dependabot/apm/file_parser.rb +313 -0
- data/lib/dependabot/apm/file_updater.rb +174 -0
- data/lib/dependabot/apm/git_commit_checker.rb +91 -0
- data/lib/dependabot/apm/metadata_finder.rb +91 -0
- data/lib/dependabot/apm/package_manager.rb +75 -0
- data/lib/dependabot/apm/package_specifier.rb +347 -0
- data/lib/dependabot/apm/requirement.rb +68 -0
- data/lib/dependabot/apm/update_checker.rb +303 -0
- data/lib/dependabot/apm/version.rb +228 -0
- data/lib/dependabot/apm.rb +28 -0
- metadata +278 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 332d6b2c76b5d6ae722f9793731e25984be0835ca971d986f9316b594982c7b2
|
|
4
|
+
data.tar.gz: b2dea7a2bb867c041702a05754820c048aaa2f6a336cad5cc4edc777dcecc6d0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e4cedd6bf6ff0eba62c8689dbf5793bba8684e4dafde2ad12873adcff61ac60f06a4c9319af598a942547a84bd2c199193434d29a550c14a518d3534c3667abb
|
|
7
|
+
data.tar.gz: 60922c751e3a27e4e1a7302f799603efd4ac732f67aa02c194e81563e1621972c9b93a0befe45566801ba16ccf1d2555babec7f0277bbde9f816fdbddba0b605
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "dependabot/errors"
|
|
7
|
+
require "dependabot/file_fetchers"
|
|
8
|
+
require "dependabot/file_fetchers/base"
|
|
9
|
+
require "dependabot/apm/package_manager"
|
|
10
|
+
|
|
11
|
+
module Dependabot
|
|
12
|
+
module Apm
|
|
13
|
+
class FileFetcher < Dependabot::FileFetchers::Base
|
|
14
|
+
extend T::Sig
|
|
15
|
+
extend T::Helpers
|
|
16
|
+
|
|
17
|
+
MANIFEST_FILENAME = "apm.yml"
|
|
18
|
+
LOCKFILE_FILENAME = "apm.lock.yaml"
|
|
19
|
+
|
|
20
|
+
# Shown when file fetching runs with beta ecosystems disabled. apm is a
|
|
21
|
+
# beta ecosystem, so it only operates when a repo opts in; surfacing the
|
|
22
|
+
# remediation here avoids the misleading "apm.yml not found" message when
|
|
23
|
+
# the manifest actually exists and the real blocker is the disabled flag.
|
|
24
|
+
BETA_DISABLED_MESSAGE =
|
|
25
|
+
"apm is a beta ecosystem. Set `enable-beta-ecosystems: true` in your " \
|
|
26
|
+
"dependabot.yml so Dependabot fetches and updates apm.yml."
|
|
27
|
+
|
|
28
|
+
sig { override.params(filenames: T::Array[String]).returns(T::Boolean) }
|
|
29
|
+
def self.required_files_in?(filenames)
|
|
30
|
+
filenames.include?(MANIFEST_FILENAME)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
sig { override.returns(String) }
|
|
34
|
+
def self.required_files_message
|
|
35
|
+
"Repo must contain an #{MANIFEST_FILENAME} file."
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { override.returns(T::Array[DependencyFile]) }
|
|
39
|
+
def fetch_files
|
|
40
|
+
# apm is a beta ecosystem, so file fetching is hidden behind the
|
|
41
|
+
# beta-ecosystems feature flag (see NEW_ECOSYSTEMS.md). When the flag is
|
|
42
|
+
# off, tell the user to enable it rather than claim the manifest is
|
|
43
|
+
# missing, which would hide the real remediation when apm.yml exists.
|
|
44
|
+
unless allow_beta_ecosystems?
|
|
45
|
+
raise Dependabot::DependencyFileNotFound.new(
|
|
46
|
+
File.join(directory, MANIFEST_FILENAME),
|
|
47
|
+
BETA_DISABLED_MESSAGE
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
fetched_files = T.let([manifest_file], T::Array[DependencyFile])
|
|
52
|
+
fetched_files << T.must(lockfile) if lockfile
|
|
53
|
+
fetched_files
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { override.returns(T.nilable(T::Hash[Symbol, T.anything])) }
|
|
57
|
+
def ecosystem_versions
|
|
58
|
+
return unless lockfile
|
|
59
|
+
|
|
60
|
+
version = parsed_lockfile_apm_version
|
|
61
|
+
return unless version
|
|
62
|
+
|
|
63
|
+
{ package_managers: { "apm" => version } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
sig { returns(Dependabot::DependencyFile) }
|
|
69
|
+
def manifest_file
|
|
70
|
+
@manifest_file ||= T.let(
|
|
71
|
+
fetch_file_from_host(MANIFEST_FILENAME),
|
|
72
|
+
T.nilable(Dependabot::DependencyFile)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
sig { returns(T.nilable(Dependabot::DependencyFile)) }
|
|
77
|
+
def lockfile
|
|
78
|
+
return @lockfile if defined?(@lockfile)
|
|
79
|
+
|
|
80
|
+
@lockfile = T.let(
|
|
81
|
+
fetch_file_if_present(LOCKFILE_FILENAME)&.tap { |f| f.support_file = true },
|
|
82
|
+
T.nilable(Dependabot::DependencyFile)
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
sig { returns(T.nilable(String)) }
|
|
87
|
+
def parsed_lockfile_apm_version
|
|
88
|
+
PackageManager.version_from_lockfile(lockfile&.content)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
Dependabot::FileFetchers
|
|
95
|
+
.register("apm", Dependabot::Apm::FileFetcher)
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
require "dependabot/dependency"
|
|
8
|
+
require "dependabot/errors"
|
|
9
|
+
require "dependabot/file_parsers"
|
|
10
|
+
require "dependabot/file_parsers/base"
|
|
11
|
+
require "dependabot/apm/package_manager"
|
|
12
|
+
require "dependabot/apm/package_specifier"
|
|
13
|
+
require "dependabot/apm/version"
|
|
14
|
+
|
|
15
|
+
module Dependabot
|
|
16
|
+
module Apm
|
|
17
|
+
class FileParser < Dependabot::FileParsers::Base
|
|
18
|
+
extend T::Sig
|
|
19
|
+
|
|
20
|
+
require "dependabot/file_parsers/base/dependency_set"
|
|
21
|
+
|
|
22
|
+
MANIFEST_FILENAME = "apm.yml"
|
|
23
|
+
LOCKFILE_FILENAME = "apm.lock.yaml"
|
|
24
|
+
|
|
25
|
+
# The manifest dependency blocks we parse, mapped to the dependency groups
|
|
26
|
+
# each block confers. Production entries carry an explicit "dependencies"
|
|
27
|
+
# marker group rather than an empty list: when DependencySet merges a
|
|
28
|
+
# package declared in both blocks, Dependency#production? flattens every
|
|
29
|
+
# requirement's groups, so an empty production marker would vanish and
|
|
30
|
+
# leave only "development", wrongly classifying the whole dependency as
|
|
31
|
+
# non-production. The explicit marker survives the merge and the registered
|
|
32
|
+
# production check treats its presence as production, while `devDependencies`
|
|
33
|
+
# entries (marked only by "development") stay non-production.
|
|
34
|
+
DEPENDENCY_BLOCKS = T.let(
|
|
35
|
+
{
|
|
36
|
+
"dependencies" => ["dependencies"].freeze,
|
|
37
|
+
"devDependencies" => ["development"].freeze
|
|
38
|
+
}.freeze,
|
|
39
|
+
T::Hash[String, T::Array[String]]
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# Block scalars (folded `>` / literal `|`) are skipped outright: multi-line
|
|
43
|
+
# folding and chomping mean their decoded value need not be a contiguous
|
|
44
|
+
# substring of the raw source span, so the updater could not rewrite them
|
|
45
|
+
# and the job would fail with "Expected content to change!". Flow scalars
|
|
46
|
+
# are additionally verified with `scalar_round_trips?`, which also excludes
|
|
47
|
+
# escaped quoted scalars whose decoding differs from their source bytes.
|
|
48
|
+
BLOCK_SCALAR_STYLES = T.let(
|
|
49
|
+
[Psych::Nodes::Scalar::LITERAL, Psych::Nodes::Scalar::FOLDED].freeze,
|
|
50
|
+
T::Array[Integer]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
sig { override.returns(T::Array[Dependabot::Dependency]) }
|
|
54
|
+
def parse
|
|
55
|
+
# Non-GitHub git hosts (e.g. GitLab) are case-sensitive, so use a
|
|
56
|
+
# case-sensitive dependency set to keep repositories that differ only by
|
|
57
|
+
# case distinct. PackageSpecifier canonicalises GitHub owner/repo casing
|
|
58
|
+
# itself, so case-insensitive GitHub paths still deduplicate correctly.
|
|
59
|
+
dependency_set = DependencySet.new(case_sensitive: true)
|
|
60
|
+
# Reading the host first also validates the manifest and raises
|
|
61
|
+
# DependencyFileNotParseable before we walk it for source positions.
|
|
62
|
+
host = default_host
|
|
63
|
+
|
|
64
|
+
# When the manifest configures a default registry, APM routes plain
|
|
65
|
+
# string-shorthand entries through that registry rather than Git. We
|
|
66
|
+
# cannot resolve registry versions (registry dependencies are out of
|
|
67
|
+
# scope for v1), so those entries are skipped rather than bumped against
|
|
68
|
+
# a git remote they may not even belong to. Explicit clone URLs are
|
|
69
|
+
# never registry-routed and are still updated.
|
|
70
|
+
skip_shorthand = default_registry_configured?
|
|
71
|
+
|
|
72
|
+
DEPENDENCY_BLOCKS.each do |block_key, groups|
|
|
73
|
+
apm_entries_in(block_key).each do |entry, declaration_span|
|
|
74
|
+
# v1 supports the string shorthand form (e.g. "owner/repo#v1.0.0").
|
|
75
|
+
# Object entries (git:/registry:/id:/path:) and `mcp` entries are
|
|
76
|
+
# not yet supported and are skipped by only reading scalar entries.
|
|
77
|
+
spec = PackageSpecifier.parse(entry, default_host: host)
|
|
78
|
+
next unless spec # local paths and unparseable specs are skipped
|
|
79
|
+
next if skip_shorthand && PackageSpecifier.shorthand?(entry)
|
|
80
|
+
|
|
81
|
+
# Only entries pinned to a semver tag are updatable. Branch-, SHA-
|
|
82
|
+
# and unpinned entries are resolved by APM's own lockfile, so we
|
|
83
|
+
# leave them out of the dependency set entirely rather than have the
|
|
84
|
+
# update checker reach out to the git remote for something we will
|
|
85
|
+
# never bump. The tag may be plain (`v1.2.0`) or package-scoped
|
|
86
|
+
# (`review--v1.2.0`), so resolve it through the shared APM extractor.
|
|
87
|
+
ref = spec.ref
|
|
88
|
+
next unless ref && Version.semver_from_ref(ref, dependency_name: spec.name)
|
|
89
|
+
|
|
90
|
+
dependency_set << build_dependency(spec, entry, declaration_span, groups)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
dependency_set.dependencies
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
sig { returns(Ecosystem) }
|
|
98
|
+
def ecosystem
|
|
99
|
+
@ecosystem ||= T.let(
|
|
100
|
+
Ecosystem.new(
|
|
101
|
+
name: ECOSYSTEM,
|
|
102
|
+
package_manager: package_manager
|
|
103
|
+
),
|
|
104
|
+
T.nilable(Dependabot::Ecosystem)
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
sig do
|
|
111
|
+
params(
|
|
112
|
+
spec: Dependabot::Apm::PackageSpecifier,
|
|
113
|
+
raw_entry: String,
|
|
114
|
+
declaration_span: String,
|
|
115
|
+
groups: T::Array[String]
|
|
116
|
+
).returns(Dependabot::Dependency)
|
|
117
|
+
end
|
|
118
|
+
def build_dependency(spec, raw_entry, declaration_span, groups)
|
|
119
|
+
ref = spec.ref
|
|
120
|
+
# The dependency version is the SemVer core of the pinned tag, even when
|
|
121
|
+
# the tag is package-scoped (`review--v1.2.0` -> `1.2.0`); the manifest
|
|
122
|
+
# requirement below keeps the original ref so the updater rewrites it.
|
|
123
|
+
core = Version.semver_from_ref(ref, dependency_name: spec.name) if ref
|
|
124
|
+
version = Version.new(core).to_s if core
|
|
125
|
+
|
|
126
|
+
Dependency.new(
|
|
127
|
+
name: spec.name,
|
|
128
|
+
version: version,
|
|
129
|
+
package_manager: "apm",
|
|
130
|
+
requirements: [{
|
|
131
|
+
requirement: nil,
|
|
132
|
+
file: manifest_file.name,
|
|
133
|
+
groups: groups,
|
|
134
|
+
source: {
|
|
135
|
+
type: "git",
|
|
136
|
+
url: spec.git_url,
|
|
137
|
+
ref: ref,
|
|
138
|
+
branch: nil
|
|
139
|
+
},
|
|
140
|
+
# `declaration_span` locates the exact manifest scalar (see
|
|
141
|
+
# `encode_span`) so the file updater rewrites only that occurrence,
|
|
142
|
+
# never an identical string elsewhere in the file.
|
|
143
|
+
metadata: {
|
|
144
|
+
declaration_string: raw_entry,
|
|
145
|
+
declaration_span: declaration_span
|
|
146
|
+
}
|
|
147
|
+
}]
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Returns each `<block>.apm` entry as a [value, encoded source span] pair.
|
|
152
|
+
# Only round-tripping flow scalar entries are returned: object-form entries
|
|
153
|
+
# (git:/registry:/id:/path: maps), block scalars (folded/literal) and
|
|
154
|
+
# escaped scalars whose decoding differs from their source bytes are all
|
|
155
|
+
# skipped, since the updater can only rewrite a value it finds verbatim in
|
|
156
|
+
# the source span.
|
|
157
|
+
sig { params(block_key: String).returns(T::Array[[String, String]]) }
|
|
158
|
+
def apm_entries_in(block_key)
|
|
159
|
+
block = ast_mapping_value(manifest_ast, block_key)
|
|
160
|
+
return [] unless block.is_a?(Psych::Nodes::Mapping)
|
|
161
|
+
|
|
162
|
+
sequence = ast_mapping_value(block, "apm")
|
|
163
|
+
return [] unless sequence.is_a?(Psych::Nodes::Sequence)
|
|
164
|
+
|
|
165
|
+
sequence.children.filter_map do |node|
|
|
166
|
+
next unless node.is_a?(Psych::Nodes::Scalar)
|
|
167
|
+
next if BLOCK_SCALAR_STYLES.include?(node.style)
|
|
168
|
+
next unless scalar_round_trips?(node)
|
|
169
|
+
|
|
170
|
+
[node.value, encode_span(node)]
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Encodes a scalar node's exact source span as
|
|
175
|
+
# "start_line:start_column:end_line:end_column" (all 0-based). The span
|
|
176
|
+
# brackets the whole token, including any surrounding quotes, which lets
|
|
177
|
+
# the updater rewrite block and flow sequences alike.
|
|
178
|
+
sig { params(node: Psych::Nodes::Scalar).returns(String) }
|
|
179
|
+
def encode_span(node)
|
|
180
|
+
[node.start_line, node.start_column, node.end_line, node.end_column].join(":")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# True when the scalar's decoded value appears verbatim inside its raw
|
|
184
|
+
# source span. The updater rewrites a ref by locating the declaration
|
|
185
|
+
# string (the decoded `node.value`) inside that span slice, so a scalar
|
|
186
|
+
# whose decoding differs from its source bytes -- e.g. a double-quoted
|
|
187
|
+
# scalar using escapes such as `\/` or `\x23` -- can never be rewritten and
|
|
188
|
+
# would fail the job with "Expected content to change!". Skipping those
|
|
189
|
+
# entries here mirrors the updater's own `original.include?(declaration)`
|
|
190
|
+
# guard, and the offset maths matches `FileUpdater#span_offsets` so an
|
|
191
|
+
# entry that survives parsing is always rewritable.
|
|
192
|
+
sig { params(node: Psych::Nodes::Scalar).returns(T::Boolean) }
|
|
193
|
+
def scalar_round_trips?(node)
|
|
194
|
+
content = T.must(manifest_file.content)
|
|
195
|
+
lines = content.each_line.to_a
|
|
196
|
+
start_offset = lines.first(node.start_line).sum(&:length) + node.start_column
|
|
197
|
+
end_offset = lines.first(node.end_line).sum(&:length) + node.end_column
|
|
198
|
+
return false if start_offset >= end_offset || end_offset > content.length
|
|
199
|
+
|
|
200
|
+
T.must(content[start_offset...end_offset]).include?(node.value)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Looks up the value node for `key` in a YAML mapping AST node, whose
|
|
204
|
+
# children alternate [key, value, key, value, ...].
|
|
205
|
+
sig do
|
|
206
|
+
params(mapping: T.nilable(Psych::Nodes::Node), key: String)
|
|
207
|
+
.returns(T.nilable(Psych::Nodes::Node))
|
|
208
|
+
end
|
|
209
|
+
def ast_mapping_value(mapping, key)
|
|
210
|
+
return unless mapping.is_a?(Psych::Nodes::Mapping)
|
|
211
|
+
|
|
212
|
+
children = mapping.children
|
|
213
|
+
index = 0
|
|
214
|
+
while index < children.length
|
|
215
|
+
node_key = children[index]
|
|
216
|
+
return children[index + 1] if node_key.is_a?(Psych::Nodes::Scalar) && node_key.value == key
|
|
217
|
+
|
|
218
|
+
index += 2
|
|
219
|
+
end
|
|
220
|
+
nil
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
sig { returns(T.nilable(Psych::Nodes::Mapping)) }
|
|
224
|
+
def manifest_ast
|
|
225
|
+
return @manifest_ast if defined?(@manifest_ast)
|
|
226
|
+
|
|
227
|
+
document = YAML.parse(T.must(manifest_file.content))
|
|
228
|
+
root = document.is_a?(Psych::Nodes::Document) ? document.root : nil
|
|
229
|
+
@manifest_ast = T.let(
|
|
230
|
+
root.is_a?(Psych::Nodes::Mapping) ? root : nil,
|
|
231
|
+
T.nilable(Psych::Nodes::Mapping)
|
|
232
|
+
)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
sig { returns(String) }
|
|
236
|
+
def default_host
|
|
237
|
+
host = parsed_manifest["default_host"]
|
|
238
|
+
host.is_a?(String) && !host.empty? ? host : PackageSpecifier::DEFAULT_HOST
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# APM routes string-shorthand dependencies through a configured default
|
|
242
|
+
# registry instead of Git. Only the project-level `registries.default`
|
|
243
|
+
# selector (a string naming a configured entry) is visible here; a user's
|
|
244
|
+
# `~/.apm/config.json` default is not part of the repository, so this
|
|
245
|
+
# guards the case that is reproducible from the manifest alone.
|
|
246
|
+
sig { returns(T::Boolean) }
|
|
247
|
+
def default_registry_configured?
|
|
248
|
+
registries = parsed_manifest["registries"]
|
|
249
|
+
return false unless registries.is_a?(Hash)
|
|
250
|
+
|
|
251
|
+
default = registries["default"]
|
|
252
|
+
return false unless default.is_a?(String)
|
|
253
|
+
|
|
254
|
+
!default.empty?
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
sig { returns(T::Hash[String, Object]) }
|
|
258
|
+
def parsed_manifest
|
|
259
|
+
@parsed_manifest ||= T.let(
|
|
260
|
+
begin
|
|
261
|
+
parsed = YAML.safe_load(T.must(manifest_file.content), aliases: true)
|
|
262
|
+
parsed = {} if parsed.nil?
|
|
263
|
+
raise Dependabot::DependencyFileNotParseable, manifest_file.path unless parsed.is_a?(Hash)
|
|
264
|
+
|
|
265
|
+
parsed
|
|
266
|
+
rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::BadAlias
|
|
267
|
+
raise Dependabot::DependencyFileNotParseable, manifest_file.path
|
|
268
|
+
end,
|
|
269
|
+
T.nilable(T::Hash[String, Object])
|
|
270
|
+
)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
sig { returns(Dependabot::DependencyFile) }
|
|
274
|
+
def manifest_file
|
|
275
|
+
@manifest_file ||= T.let(
|
|
276
|
+
T.must(get_original_file(MANIFEST_FILENAME)),
|
|
277
|
+
T.nilable(Dependabot::DependencyFile)
|
|
278
|
+
)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
sig { returns(T.nilable(Dependabot::DependencyFile)) }
|
|
282
|
+
def lockfile
|
|
283
|
+
@lockfile ||= T.let(
|
|
284
|
+
get_original_file(LOCKFILE_FILENAME),
|
|
285
|
+
T.nilable(Dependabot::DependencyFile)
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
sig { override.void }
|
|
290
|
+
def check_required_files
|
|
291
|
+
return if get_original_file(MANIFEST_FILENAME)
|
|
292
|
+
|
|
293
|
+
raise "No #{MANIFEST_FILENAME}!"
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
sig { returns(Ecosystem::VersionManager) }
|
|
297
|
+
def package_manager
|
|
298
|
+
@package_manager ||= T.let(
|
|
299
|
+
PackageManager.new(apm_version),
|
|
300
|
+
T.nilable(Dependabot::Apm::PackageManager)
|
|
301
|
+
)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
sig { returns(String) }
|
|
305
|
+
def apm_version
|
|
306
|
+
PackageManager.version_from_lockfile(lockfile&.content) || DEFAULT_PACKAGE_MANAGER_VERSION
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
Dependabot::FileParsers
|
|
313
|
+
.register("apm", Dependabot::Apm::FileParser)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "dependabot/errors"
|
|
7
|
+
require "dependabot/file_updaters"
|
|
8
|
+
require "dependabot/file_updaters/base"
|
|
9
|
+
|
|
10
|
+
module Dependabot
|
|
11
|
+
module Apm
|
|
12
|
+
class FileUpdater < Dependabot::FileUpdaters::Base
|
|
13
|
+
extend T::Sig
|
|
14
|
+
|
|
15
|
+
MANIFEST_FILENAME = "apm.yml"
|
|
16
|
+
|
|
17
|
+
# A single ref-bump edit, located by an absolute [start_offset, end_offset)
|
|
18
|
+
# character range in the original manifest content.
|
|
19
|
+
class Substitution < T::Struct
|
|
20
|
+
const :start_offset, Integer
|
|
21
|
+
const :end_offset, Integer
|
|
22
|
+
const :text, String
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
sig { returns(T::Array[Regexp]) }
|
|
26
|
+
def self.updated_files_regex
|
|
27
|
+
[/^apm\.yml$/]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { override.returns(T::Array[Dependabot::DependencyFile]) }
|
|
31
|
+
def updated_dependency_files
|
|
32
|
+
updated_files = manifest_files.filter_map do |file|
|
|
33
|
+
next unless file_changed?(file)
|
|
34
|
+
|
|
35
|
+
updated_file(file: file, content: updated_manifest_content(file))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
raise "No files changed!" if updated_files.none?
|
|
39
|
+
|
|
40
|
+
updated_files
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
sig { override.void }
|
|
46
|
+
def check_required_files
|
|
47
|
+
return if get_original_file(MANIFEST_FILENAME)
|
|
48
|
+
|
|
49
|
+
raise "No #{MANIFEST_FILENAME}!"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
sig { returns(T::Array[Dependabot::DependencyFile]) }
|
|
53
|
+
def manifest_files
|
|
54
|
+
dependency_files.select { |f| File.basename(f.name) == MANIFEST_FILENAME }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
sig { params(file: Dependabot::DependencyFile).returns(T::Boolean) }
|
|
58
|
+
def file_changed?(file)
|
|
59
|
+
dependencies.any? { |dep| requirement_changed?(file, dep) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { params(file: Dependabot::DependencyFile).returns(String) }
|
|
63
|
+
def updated_manifest_content(file)
|
|
64
|
+
original_content = T.must(file.content)
|
|
65
|
+
content = apply_substitutions(original_content, substitutions_for(file, original_content))
|
|
66
|
+
|
|
67
|
+
raise "Expected content to change!" if content == original_content
|
|
68
|
+
|
|
69
|
+
content
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Collects every ref-bump substitution for `file`, each located by an
|
|
73
|
+
# absolute range in the ORIGINAL content. Resolving all offsets against the
|
|
74
|
+
# original (rather than mutating between edits) is what lets
|
|
75
|
+
# `apply_substitutions` reorder them safely.
|
|
76
|
+
sig { params(file: Dependabot::DependencyFile, content: String).returns(T::Array[Substitution]) }
|
|
77
|
+
def substitutions_for(file, content)
|
|
78
|
+
dependencies.flat_map do |dependency|
|
|
79
|
+
previous_requirements = dependency.previous_requirements || []
|
|
80
|
+
|
|
81
|
+
dependency.requirements.filter_map do |new_req|
|
|
82
|
+
next unless new_req.file == file.name
|
|
83
|
+
|
|
84
|
+
substitution_for(content, new_req, previous_requirements)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Builds the substitution for a single requirement. The replacement rewrites
|
|
90
|
+
# only the declaration substring inside the scalar's span slice, so any
|
|
91
|
+
# surrounding quotes are preserved and an identical string elsewhere in the
|
|
92
|
+
# file is never touched.
|
|
93
|
+
sig do
|
|
94
|
+
params(
|
|
95
|
+
content: String,
|
|
96
|
+
new_req: Dependabot::DependencyRequirement,
|
|
97
|
+
previous_requirements: T::Array[Dependabot::DependencyRequirement]
|
|
98
|
+
).returns(T.nilable(Substitution))
|
|
99
|
+
end
|
|
100
|
+
def substitution_for(content, new_req, previous_requirements)
|
|
101
|
+
declaration = new_req.metadata_string("declaration_string")
|
|
102
|
+
new_ref = new_req.source_string("ref")
|
|
103
|
+
return unless declaration && new_ref
|
|
104
|
+
|
|
105
|
+
old_req = previous_requirements.find do |req|
|
|
106
|
+
req.metadata_string("declaration_string") == declaration
|
|
107
|
+
end
|
|
108
|
+
old_ref = old_req&.source_string("ref")
|
|
109
|
+
return unless old_ref && old_ref != new_ref
|
|
110
|
+
|
|
111
|
+
# A quoted scalar may carry trailing whitespace after the ref (e.g.
|
|
112
|
+
# `"owner/repo#v1.0.0 "`). PackageSpecifier strips it before parsing the
|
|
113
|
+
# ref, but the stored declaration keeps it, so anchor the match before any
|
|
114
|
+
# trailing whitespace and preserve that whitespace in the rewrite. An
|
|
115
|
+
# end-anchored `##{old_ref}` check would otherwise miss and fail the whole
|
|
116
|
+
# update with "Expected content to change!".
|
|
117
|
+
ref_at_end = /##{Regexp.escape(old_ref)}(\s*)\z/
|
|
118
|
+
return unless declaration.match?(ref_at_end)
|
|
119
|
+
|
|
120
|
+
offsets = span_offsets(content, new_req.metadata_string("declaration_span"))
|
|
121
|
+
return unless offsets
|
|
122
|
+
|
|
123
|
+
start_offset, end_offset = offsets
|
|
124
|
+
original = T.must(content[start_offset...end_offset])
|
|
125
|
+
return unless original.include?(declaration)
|
|
126
|
+
|
|
127
|
+
new_declaration = declaration.sub(ref_at_end, "##{new_ref}\\1")
|
|
128
|
+
Substitution.new(
|
|
129
|
+
start_offset: start_offset,
|
|
130
|
+
end_offset: end_offset,
|
|
131
|
+
text: original.sub(declaration, new_declaration)
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Applies the substitutions from the end of the file backwards. Rewriting in
|
|
136
|
+
# descending start offset means a length change in one edit can never shift
|
|
137
|
+
# the offsets of the edits still to be applied, so duplicate entries sharing
|
|
138
|
+
# a line (e.g. a flow sequence) are all updated correctly.
|
|
139
|
+
sig { params(content: String, substitutions: T::Array[Substitution]).returns(String) }
|
|
140
|
+
def apply_substitutions(content, substitutions)
|
|
141
|
+
substitutions.sort_by(&:start_offset).reverse.reduce(content) do |result, sub|
|
|
142
|
+
"#{T.must(result[0...sub.start_offset])}#{sub.text}#{result[sub.end_offset..]}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Resolves the encoded span to an absolute [start, end) character range in
|
|
147
|
+
# `content`, or nil when the span is missing, malformed, or points past the
|
|
148
|
+
# end of the current content.
|
|
149
|
+
sig { params(content: String, declaration_span: T.nilable(String)).returns(T.nilable([Integer, Integer])) }
|
|
150
|
+
def span_offsets(content, declaration_span)
|
|
151
|
+
return unless declaration_span
|
|
152
|
+
|
|
153
|
+
parts = declaration_span.split(":")
|
|
154
|
+
return unless parts.length == 4
|
|
155
|
+
|
|
156
|
+
lines = content.each_line.to_a
|
|
157
|
+
start_offset = line_offset(lines, T.must(parts[0]).to_i) + T.must(parts[1]).to_i
|
|
158
|
+
end_offset = line_offset(lines, T.must(parts[2]).to_i) + T.must(parts[3]).to_i
|
|
159
|
+
return if start_offset >= end_offset || end_offset > content.length
|
|
160
|
+
|
|
161
|
+
[start_offset, end_offset]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Character offset of the start of the given 0-based line.
|
|
165
|
+
sig { params(lines: T::Array[String], line: Integer).returns(Integer) }
|
|
166
|
+
def line_offset(lines, line)
|
|
167
|
+
lines.first(line).sum(&:length)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
Dependabot::FileUpdaters
|
|
174
|
+
.register("apm", Dependabot::Apm::FileUpdater)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# typed: strong
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "sorbet-runtime"
|
|
5
|
+
|
|
6
|
+
require "dependabot/git_commit_checker"
|
|
7
|
+
require "dependabot/apm/version"
|
|
8
|
+
|
|
9
|
+
module Dependabot
|
|
10
|
+
module Apm
|
|
11
|
+
# A GitCommitChecker specialised for APM's strict SemVer 2.0.0 tags.
|
|
12
|
+
#
|
|
13
|
+
# The shared Dependabot::GitCommitChecker recognises and parses version tags
|
|
14
|
+
# with a RubyGems-oriented VERSION_REGEX that is incompatible with APM's
|
|
15
|
+
# strict SemVer grammar in two ways:
|
|
16
|
+
#
|
|
17
|
+
# * it rejects valid SemVer build metadata (e.g. `v1.2.0+build.5`), so
|
|
18
|
+
# those releases would never be offered as updates; and
|
|
19
|
+
# * it accepts non-SemVer tags such as `v1.2.3.4`, which then raise
|
|
20
|
+
# ArgumentError when the checker builds a strict Apm::Version.
|
|
21
|
+
#
|
|
22
|
+
# Overriding just the version-grammar seams (tag recognition, version
|
|
23
|
+
# extraction, prefix comparison and latest-tag selection) routes every tag
|
|
24
|
+
# through Apm::Version, while reusing all of the shared cooldown, ignore,
|
|
25
|
+
# prerelease and security handling.
|
|
26
|
+
#
|
|
27
|
+
# Besides plain `v?<semver>` tags, APM also recognises package-scoped tags
|
|
28
|
+
# so a monorepo can release each package independently: `{name}_v{version}`,
|
|
29
|
+
# `{name}--v{version}` and `{name}-v{version}`, where `{name}` is the
|
|
30
|
+
# package's own name (the repository name, or the final component of a
|
|
31
|
+
# virtual package path). A repository that only publishes `review--v1.5.0`
|
|
32
|
+
# would otherwise offer no update, so those forms are parsed too, with the
|
|
33
|
+
# accepted prefix scoped to this dependency's package name. That grammar
|
|
34
|
+
# lives in `Apm::Version.semver_from_ref`, shared with the file parser and
|
|
35
|
+
# update checker so every consumer reads refs identically.
|
|
36
|
+
class GitCommitChecker < Dependabot::GitCommitChecker
|
|
37
|
+
extend T::Sig
|
|
38
|
+
|
|
39
|
+
# Build metadata is not part of SemVer precedence, so tags such as
|
|
40
|
+
# `v1.3.0+build.5` and `v1.3.0+build.9` compare equal and the inherited
|
|
41
|
+
# `max_by { version_from_tag(...) }` would keep whichever the remote
|
|
42
|
+
# advertised first. Break that tie by the highest full tag string, as APM
|
|
43
|
+
# does, so latest-tag selection is deterministic regardless of ref order.
|
|
44
|
+
# Public to match the shared checker, which exposes this selection seam.
|
|
45
|
+
sig { override.params(tags: T::Array[Dependabot::GitRef]).returns(T.nilable(Dependabot::GitTagDetails)) }
|
|
46
|
+
def max_local_tag(tags)
|
|
47
|
+
max_version_tag = tags.max_by { |tag| [version_from_tag(tag), tag.name] }
|
|
48
|
+
|
|
49
|
+
to_local_tag(max_version_tag)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# Recognise a tag as a version only when it is a valid APM SemVer ref
|
|
55
|
+
# (optionally package-scoped), so non-SemVer tags (e.g. `v1.2.3.4`) are
|
|
56
|
+
# dropped before they can raise an ArgumentError, while build-metadata
|
|
57
|
+
# tags (e.g. `v1.2.0+build.5`) and package-scoped tags (e.g.
|
|
58
|
+
# `review--v1.5.0`) are kept.
|
|
59
|
+
sig { override.params(tag: String).returns(T::Boolean) }
|
|
60
|
+
def version_tag?(tag)
|
|
61
|
+
!Dependabot::Apm::Version.semver_from_ref(tag, dependency_name: dependency.name).nil?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Extract the SemVer core of a tag, keeping any build metadata so
|
|
65
|
+
# Apm::Version can parse it. Tags reach here only after #version_tag?, so
|
|
66
|
+
# the tag is always a plain `v?<semver>` or a recognised package-scoped
|
|
67
|
+
# form and the extraction never returns nil.
|
|
68
|
+
sig { override.params(name: String).returns(String) }
|
|
69
|
+
def scan_version(name)
|
|
70
|
+
T.must(Dependabot::Apm::Version.semver_from_ref(name, dependency_name: dependency.name))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# An APM tag's prefix is whatever precedes its SemVer core: the optional
|
|
74
|
+
# `v` on a plain tag, or `{name}_v` / `{name}--v` / `{name}-v` on a
|
|
75
|
+
# package-scoped one. Strip the SemVer core (which the shared VERSION_REGEX
|
|
76
|
+
# cannot remove once build metadata is present) and compare what remains,
|
|
77
|
+
# treating `v` and no prefix as equivalent to match the shared checker's
|
|
78
|
+
# lenient behaviour, so a pinned plain tag keeps resolving plain tags and a
|
|
79
|
+
# pinned scoped tag keeps resolving that package's scoped tags.
|
|
80
|
+
sig { override.params(tag: String, other_tag: String).returns(T::Boolean) }
|
|
81
|
+
def same_prefix?(tag, other_tag)
|
|
82
|
+
normalize_v_prefix(version_prefix(tag)) == normalize_v_prefix(version_prefix(other_tag))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
sig { params(tag: String).returns(String) }
|
|
86
|
+
def version_prefix(tag)
|
|
87
|
+
tag.delete_suffix(scan_version(tag))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|