dependabot-apm 0.400.0 → 0.402.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 332d6b2c76b5d6ae722f9793731e25984be0835ca971d986f9316b594982c7b2
4
- data.tar.gz: b2dea7a2bb867c041702a05754820c048aaa2f6a336cad5cc4edc777dcecc6d0
3
+ metadata.gz: 6ba8a0fe03d88d3c9a98aba6d2f8fd7c4d235bded82fd3e158a4dac7936def80
4
+ data.tar.gz: 19c19651c79805c932dc12b4e84b9b579bf8db15f6c131a0e0cd82947cefc1b2
5
5
  SHA512:
6
- metadata.gz: e4cedd6bf6ff0eba62c8689dbf5793bba8684e4dafde2ad12873adcff61ac60f06a4c9319af598a942547a84bd2c199193434d29a550c14a518d3534c3667abb
7
- data.tar.gz: 60922c751e3a27e4e1a7302f799603efd4ac732f67aa02c194e81563e1621972c9b93a0befe45566801ba16ccf1d2555babec7f0277bbde9f816fdbddba0b605
6
+ metadata.gz: 71dc26756e87e1412601d85d0ae7f72f8849289194abc3ce0e29211000ccd991439ffe846ac60d8a4d1432343ca6e8072732ed1ea919bd83fc60c81e67f6edd9
7
+ data.tar.gz: d15140aaef4a1e2e74eb510d8ab4f25774d36abaf8fffa428af782a46edf782784c1c3742771c0f648d15159d98b5b519c458f6fb4ed54c5ecdc46c64f35f7d7
@@ -1,4 +1,4 @@
1
- # typed: strict
1
+ # typed: strong
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "sorbet-runtime"
@@ -78,7 +78,7 @@ module Dependabot
78
78
  return @lockfile if defined?(@lockfile)
79
79
 
80
80
  @lockfile = T.let(
81
- fetch_file_if_present(LOCKFILE_FILENAME)&.tap { |f| f.support_file = true },
81
+ fetch_file_if_present(LOCKFILE_FILENAME),
82
82
  T.nilable(Dependabot::DependencyFile)
83
83
  )
84
84
  end
@@ -8,6 +8,7 @@ require "dependabot/dependency"
8
8
  require "dependabot/errors"
9
9
  require "dependabot/file_parsers"
10
10
  require "dependabot/file_parsers/base"
11
+ require "dependabot/apm/lockfile"
11
12
  require "dependabot/apm/package_manager"
12
13
  require "dependabot/apm/package_specifier"
13
14
  require "dependabot/apm/version"
@@ -50,6 +51,23 @@ module Dependabot
50
51
  T::Array[Integer]
51
52
  )
52
53
 
54
+ PIN_TAG = "tag"
55
+ PIN_SHA = "sha"
56
+ PIN_BRANCH = "branch"
57
+ PIN_PRECEDENCE = T.let([PIN_TAG, PIN_SHA, PIN_BRANCH].freeze, T::Array[String])
58
+
59
+ # A manifest scalar entry and the dependency groups its block confers.
60
+ class Declaration < T::Struct
61
+ const :raw_entry, String
62
+ const :span, String
63
+ const :groups, T::Array[String]
64
+ end
65
+
66
+ class PinnedEntry < T::Struct
67
+ const :pin_type, String
68
+ const :dependency, Dependabot::Dependency
69
+ end
70
+
53
71
  sig { override.returns(T::Array[Dependabot::Dependency]) }
54
72
  def parse
55
73
  # Non-GitHub git hosts (e.g. GitLab) are case-sensitive, so use a
@@ -57,9 +75,9 @@ module Dependabot
57
75
  # case distinct. PackageSpecifier canonicalises GitHub owner/repo casing
58
76
  # itself, so case-insensitive GitHub paths still deduplicate correctly.
59
77
  dependency_set = DependencySet.new(case_sensitive: true)
60
- # Reading the host first also validates the manifest and raises
78
+ # Reading the manifest first validates it and raises
61
79
  # DependencyFileNotParseable before we walk it for source positions.
62
- host = default_host
80
+ parsed_manifest
63
81
 
64
82
  # When the manifest configures a default registry, APM routes plain
65
83
  # string-shorthand entries through that registry rather than Git. We
@@ -69,28 +87,27 @@ module Dependabot
69
87
  # never registry-routed and are still updated.
70
88
  skip_shorthand = default_registry_configured?
71
89
 
72
- DEPENDENCY_BLOCKS.each do |block_key, groups|
73
- apm_entries_in(block_key).each do |entry, declaration_span|
90
+ pinned_entries = DEPENDENCY_BLOCKS.flat_map do |block_key, groups|
91
+ apm_entries_in(block_key).filter_map do |entry, declaration_span|
74
92
  # v1 supports the string shorthand form (e.g. "owner/repo#v1.0.0").
75
93
  # Object entries (git:/registry:/id:/path:) and `mcp` entries are
76
94
  # not yet supported and are skipped by only reading scalar entries.
77
- spec = PackageSpecifier.parse(entry, default_host: host)
95
+ # Shorthands resolve against github.com: APM reads no manifest-level
96
+ # default host (only the GITHUB_HOST env var, which Dependabot does
97
+ # not set for the CLI), so `apm lock` resolves them there too.
98
+ spec = PackageSpecifier.parse(entry)
78
99
  next unless spec # local paths and unparseable specs are skipped
79
100
  next if skip_shorthand && PackageSpecifier.shorthand?(entry)
80
101
 
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)
102
+ pinned_entry(spec, entry, declaration_span, groups)
91
103
  end
92
104
  end
93
105
 
106
+ preferred_pins = preferred_pin_types(pinned_entries)
107
+ pinned_entries.each do |entry|
108
+ dependency_set << entry.dependency if preferred_pins[entry.dependency.name] == entry.pin_type
109
+ end
110
+
94
111
  dependency_set.dependencies
95
112
  end
96
113
 
@@ -107,21 +124,97 @@ module Dependabot
107
124
 
108
125
  private
109
126
 
127
+ # Classifies a pinned manifest entry and builds its dependency, or returns
128
+ # nil for entries Dependabot doesn't update:
129
+ #
130
+ # * tag pins (`v1.2.0`, or package-scoped `review--v1.2.0`) are versioned
131
+ # by the SemVer core of the tag;
132
+ # * full 40-character SHA pins are versioned by the SHA, matching the
133
+ # pins `apm update` moves between annotated release tags;
134
+ # * any other ref is a branch (or similar moving ref) and is versioned by
135
+ # the commit the lockfile resolved it to, so it is only updatable when
136
+ # the lockfile records a commit for that ref.
137
+ #
138
+ # Unpinned entries and abbreviated SHAs are skipped, as APM leaves those
139
+ # unchanged too.
110
140
  sig do
111
141
  params(
112
142
  spec: Dependabot::Apm::PackageSpecifier,
113
143
  raw_entry: String,
114
144
  declaration_span: String,
115
145
  groups: T::Array[String]
116
- ).returns(Dependabot::Dependency)
146
+ ).returns(T.nilable(PinnedEntry))
117
147
  end
118
- def build_dependency(spec, raw_entry, declaration_span, groups)
148
+ def pinned_entry(spec, raw_entry, declaration_span, groups)
119
149
  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
150
+ return if ref.nil? || ref.empty?
151
+
152
+ declaration = Declaration.new(raw_entry: raw_entry, span: declaration_span, groups: groups)
153
+ tag_version = Version.semver_from_ref(ref, dependency_name: spec.name)
154
+ if tag_version
155
+ return PinnedEntry.new(
156
+ pin_type: PIN_TAG,
157
+ dependency: build_dependency(spec, declaration, version: Version.new(tag_version).to_s)
158
+ )
159
+ end
160
+ if ref.match?(PackageSpecifier::FULL_SHA_REGEX)
161
+ return PinnedEntry.new(
162
+ pin_type: PIN_SHA,
163
+ dependency: build_dependency(spec, declaration, version: ref.downcase)
164
+ )
165
+ end
166
+ return if ref.match?(PackageSpecifier::ABBREVIATED_SHA_REGEX)
167
+
168
+ branch_pinned_entry(spec, declaration, ref)
169
+ end
170
+
171
+ sig do
172
+ params(spec: Dependabot::Apm::PackageSpecifier, declaration: Declaration, branch: String)
173
+ .returns(T.nilable(PinnedEntry))
174
+ end
175
+ def branch_pinned_entry(spec, declaration, branch)
176
+ lockfile_key = spec.lockfile_key
177
+ commit = parsed_lockfile&.resolved_commit(lockfile_key, ref: branch)
178
+ return unless commit
179
+
180
+ PinnedEntry.new(
181
+ pin_type: PIN_BRANCH,
182
+ dependency: build_dependency(spec, declaration, version: commit, branch: branch, lockfile_key: lockfile_key)
183
+ )
184
+ end
185
+
186
+ # DependencySet merges every declaration of a package into one dependency,
187
+ # but a dependency can only carry one kind of version (a tag version or a
188
+ # commit). When a package is declared with several kinds of pin, only the
189
+ # declarations of the most specific kind (tag, then SHA, then branch) are
190
+ # updated; the others are left as they are.
191
+ sig { params(entries: T::Array[PinnedEntry]).returns(T::Hash[String, String]) }
192
+ def preferred_pin_types(entries)
193
+ entries.group_by { |entry| entry.dependency.name }.transform_values do |pins|
194
+ T.must(pins.map(&:pin_type).min_by { |pin_type| T.must(PIN_PRECEDENCE.index(pin_type)) })
195
+ end
196
+ end
197
+
198
+ sig do
199
+ params(
200
+ spec: Dependabot::Apm::PackageSpecifier,
201
+ declaration: Declaration,
202
+ version: String,
203
+ branch: T.nilable(String),
204
+ lockfile_key: T.nilable(String)
205
+ ).returns(Dependabot::Dependency)
206
+ end
207
+ def build_dependency(spec, declaration, version:, branch: nil, lockfile_key: nil)
208
+ # `declaration_span` locates the exact manifest scalar (see
209
+ # `encode_span`) so the file updater rewrites only that occurrence,
210
+ # never an identical string elsewhere in the file. Branch pins also
211
+ # record their lockfile identity so the lockfile updater can move the
212
+ # matching lock entry to the branch's new head commit.
213
+ metadata = T.let(
214
+ { declaration_string: declaration.raw_entry, declaration_span: declaration.span },
215
+ T::Hash[Symbol, String]
216
+ )
217
+ metadata[:lockfile_key] = lockfile_key if lockfile_key
125
218
 
126
219
  Dependency.new(
127
220
  name: spec.name,
@@ -130,20 +223,14 @@ module Dependabot
130
223
  requirements: [{
131
224
  requirement: nil,
132
225
  file: manifest_file.name,
133
- groups: groups,
226
+ groups: declaration.groups,
134
227
  source: {
135
228
  type: "git",
136
229
  url: spec.git_url,
137
- ref: ref,
138
- branch: nil
230
+ ref: spec.ref,
231
+ branch: branch
139
232
  },
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
- }
233
+ metadata: metadata
147
234
  }]
148
235
  )
149
236
  end
@@ -232,12 +319,6 @@ module Dependabot
232
319
  )
233
320
  end
234
321
 
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
322
  # APM routes string-shorthand dependencies through a configured default
242
323
  # registry instead of Git. Only the project-level `registries.default`
243
324
  # selector (a string naming a configured entry) is visible here; a user's
@@ -286,6 +367,14 @@ module Dependabot
286
367
  )
287
368
  end
288
369
 
370
+ sig { returns(T.nilable(Dependabot::Apm::Lockfile)) }
371
+ def parsed_lockfile
372
+ return @parsed_lockfile if defined?(@parsed_lockfile)
373
+
374
+ file = lockfile
375
+ @parsed_lockfile = T.let(file && Lockfile.new(file), T.nilable(Dependabot::Apm::Lockfile))
376
+ end
377
+
289
378
  sig { override.void }
290
379
  def check_required_files
291
380
  return if get_original_file(MANIFEST_FILENAME)
@@ -0,0 +1,175 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "fileutils"
5
+ require "sorbet-runtime"
6
+
7
+ require "dependabot/errors"
8
+ require "dependabot/shared_helpers"
9
+ require "dependabot/apm/file_updater"
10
+ require "dependabot/apm/lockfile"
11
+ require "dependabot/apm/native_helpers"
12
+
13
+ module Dependabot
14
+ module Apm
15
+ class FileUpdater < Dependabot::FileUpdaters::Base
16
+ # Regenerates `apm.lock.yaml` with `apm lock`, which resolves the updated
17
+ # manifest without deploying any package files. APM only re-resolves the
18
+ # entries whose manifest ref changed, keeping every other entry on its
19
+ # locked commit, so tag and SHA updates are picked up from the manifest.
20
+ # A branch pin's ref doesn't change, so its lock entry is first moved to
21
+ # the branch's new head commit for APM to verify and re-hash.
22
+ class LockfileUpdater
23
+ extend T::Sig
24
+
25
+ APM_MODULES_DIRECTORY = "apm_modules"
26
+
27
+ AUTHENTICATION_FAILED_REGEX = /Authentication failed for '(?<url>[^']+)'/
28
+ REPOSITORY_NOT_FOUND_REGEX = /repository '(?<url>[^']+)' not found/i
29
+ # APM replaces git's error with its own when a clone is rejected or the
30
+ # repository is missing, e.g. "Failed to clone repository x/y.
31
+ # Authentication failed for clone on github.com."
32
+ APM_AUTHENTICATION_FAILED_REGEX = /
33
+ Failed\sto\sclone\srepository\s(?<repo>[^\s:]+?)\.\s
34
+ Authentication\sfailed\sfor\s[^\n]*?\son\s(?<host>\S+?)\.(?=\s|\z)
35
+ /x
36
+ FAILURE_PREFIX = /Failed\sto\sdownload\sdependency\s|Failed\sto\sresolve\stransitive\sdep\s/
37
+ # One failed package: its name (with an optional transitive `(via …)`
38
+ # chain) and the diagnostic up to the next failure in APM's output.
39
+ PACKAGE_FAILURE_REGEX = /
40
+ (?:#{FAILURE_PREFIX})(?<name>\S+?)(?:\s\(via\s[^)]*\))?:\s
41
+ (?<detail>.*?)(?=\n\s*\+-\s|#{FAILURE_PREFIX}|\z)
42
+ /mx
43
+ # Diagnostics that show a ref is missing. APM's own "Could not resolve
44
+ # commit/reference" wrapper is deliberately not matched: it wraps any
45
+ # failure, including network errors, so only the detail it carries
46
+ # (APM's or git's) says whether the ref exists.
47
+ MISSING_REFERENCE_REGEX = /
48
+ Remote\sbranch\s\S+\snot\sfound|
49
+ Reference\s'[^']+'\snot\sfound|
50
+ Needed\sa\ssingle\srevision|
51
+ unable\sto\sread\stree|
52
+ did\snot\smatch\sany\sfile\(s\)\sknown\sto\sgit|
53
+ reference\sis\snot\sa\stree
54
+ /x
55
+
56
+ sig do
57
+ params(
58
+ dependencies: T::Array[Dependabot::Dependency],
59
+ lockfile: Dependabot::DependencyFile,
60
+ manifest_content: String,
61
+ credentials: T::Array[Dependabot::Credential],
62
+ repo_contents_path: T.nilable(String)
63
+ ).void
64
+ end
65
+ def initialize(dependencies:, lockfile:, manifest_content:, credentials:, repo_contents_path:)
66
+ @dependencies = dependencies
67
+ @lockfile = lockfile
68
+ @manifest_content = manifest_content
69
+ @credentials = credentials
70
+ @repo_contents_path = repo_contents_path
71
+ end
72
+
73
+ sig { returns(String) }
74
+ def updated_lockfile_content
75
+ SharedHelpers.in_a_temporary_repo_directory(lockfile.directory, repo_contents_path) do
76
+ # `apm lock` downloads packages into apm_modules/ and resolves
77
+ # transitive dependencies from what is already there. The git reset
78
+ # between updates doesn't remove that ignored directory, so clear it
79
+ # to keep one update's packages from leaking into the next.
80
+ FileUtils.rm_rf(APM_MODULES_DIRECTORY)
81
+ File.write(FileUpdater::MANIFEST_FILENAME, manifest_content)
82
+ File.write(FileUpdater::LOCKFILE_FILENAME, prepared_lockfile_content)
83
+
84
+ SharedHelpers.with_git_configured(credentials: credentials) do
85
+ NativeHelpers.run_apm_command("lock")
86
+ end
87
+
88
+ File.read(FileUpdater::LOCKFILE_FILENAME)
89
+ ensure
90
+ FileUtils.rm_rf(APM_MODULES_DIRECTORY)
91
+ end
92
+ rescue SharedHelpers::HelperSubprocessFailed => e
93
+ handle_apm_lock_error(e)
94
+ end
95
+
96
+ private
97
+
98
+ sig { returns(T::Array[Dependabot::Dependency]) }
99
+ attr_reader :dependencies
100
+
101
+ sig { returns(Dependabot::DependencyFile) }
102
+ attr_reader :lockfile
103
+
104
+ sig { returns(String) }
105
+ attr_reader :manifest_content
106
+
107
+ sig { returns(T::Array[Dependabot::Credential]) }
108
+ attr_reader :credentials
109
+
110
+ sig { returns(T.nilable(String)) }
111
+ attr_reader :repo_contents_path
112
+
113
+ sig { returns(String) }
114
+ def prepared_lockfile_content
115
+ branch_updates = updated_branch_pins
116
+ return T.must(lockfile.content) if branch_updates.empty?
117
+
118
+ parsed_lockfile = Lockfile.new(lockfile)
119
+ branch_updates.each { |key, commit| parsed_lockfile.pin_commit(key, commit) }
120
+ parsed_lockfile.to_yaml
121
+ end
122
+
123
+ # The new commit of each updated branch pin, keyed by its lockfile entry.
124
+ sig { returns(T::Hash[String, String]) }
125
+ def updated_branch_pins
126
+ dependencies.each_with_object({}) do |dependency, pins|
127
+ new_commit = dependency.version
128
+ next if new_commit.nil? || new_commit == dependency.previous_version
129
+
130
+ dependency.requirements.each do |requirement|
131
+ key = requirement.metadata_string("lockfile_key")
132
+ pins[key] = new_commit if key && requirement.source_string("branch")
133
+ end
134
+ end
135
+ end
136
+
137
+ sig { params(error: SharedHelpers::HelperSubprocessFailed).returns(T.noreturn) }
138
+ def handle_apm_lock_error(error)
139
+ message = error.message
140
+
141
+ unreachable_url = unreachable_repository_url(message)
142
+ raise Dependabot::GitDependenciesNotReachable, unreachable_url if unreachable_url
143
+
144
+ missing_reference = package_with_missing_reference(message)
145
+ raise Dependabot::GitDependencyReferenceNotFound, missing_reference if missing_reference
146
+
147
+ raise Dependabot::DependencyFileNotResolvable, message
148
+ end
149
+
150
+ sig { params(message: String).returns(T.nilable(String)) }
151
+ def unreachable_repository_url(message)
152
+ git_url = message.match(AUTHENTICATION_FAILED_REGEX)&.[](:url) ||
153
+ message.match(REPOSITORY_NOT_FOUND_REGEX)&.[](:url)
154
+ return git_url if git_url
155
+
156
+ apm_failure = message.match(APM_AUTHENTICATION_FAILED_REGEX)
157
+ "https://#{apm_failure[:host]}/#{apm_failure[:repo]}" if apm_failure
158
+ end
159
+
160
+ # Checks each failed package's own diagnostic, so a missing ref is
161
+ # attributed to the package it belongs to rather than an earlier failure.
162
+ sig { params(message: String).returns(T.nilable(String)) }
163
+ def package_with_missing_reference(message)
164
+ position = 0
165
+ while (failure = PACKAGE_FAILURE_REGEX.match(message, position))
166
+ return failure[:name] if failure[:detail].to_s.match?(MISSING_REFERENCE_REGEX)
167
+
168
+ position = failure.end(0)
169
+ end
170
+ nil
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -13,6 +13,13 @@ module Dependabot
13
13
  extend T::Sig
14
14
 
15
15
  MANIFEST_FILENAME = "apm.yml"
16
+ LOCKFILE_FILENAME = "apm.lock.yaml"
17
+
18
+ # What may follow a SHA pin on its line for `apm update`'s `# <tag>`
19
+ # comment to be written: nothing but whitespace or an existing comment.
20
+ LINE_REMAINDER_REGEX = /\A[ \t]*(?:#[^\r\n]*)?(?=\r?\n|\z)/
21
+
22
+ require_relative "file_updater/lockfile_updater"
16
23
 
17
24
  # A single ref-bump edit, located by an absolute [start_offset, end_offset)
18
25
  # character range in the original manifest content.
@@ -24,7 +31,7 @@ module Dependabot
24
31
 
25
32
  sig { returns(T::Array[Regexp]) }
26
33
  def self.updated_files_regex
27
- [/^apm\.yml$/]
34
+ [/^apm\.yml$/, /^apm\.lock\.yaml$/]
28
35
  end
29
36
 
30
37
  sig { override.returns(T::Array[Dependabot::DependencyFile]) }
@@ -35,6 +42,9 @@ module Dependabot
35
42
  updated_file(file: file, content: updated_manifest_content(file))
36
43
  end
37
44
 
45
+ updated_lockfile = updated_lockfile(updated_files)
46
+ updated_files << updated_lockfile if updated_lockfile
47
+
38
48
  raise "No files changed!" if updated_files.none?
39
49
 
40
50
  updated_files
@@ -59,6 +69,31 @@ module Dependabot
59
69
  dependencies.any? { |dep| requirement_changed?(file, dep) }
60
70
  end
61
71
 
72
+ # Regenerates the lockfile against the updated manifest. A branch pin
73
+ # update changes only the lockfile, as the manifest still names the branch.
74
+ # The lockfile is only ever updated, never created.
75
+ sig do
76
+ params(updated_manifests: T::Array[Dependabot::DependencyFile])
77
+ .returns(T.nilable(Dependabot::DependencyFile))
78
+ end
79
+ def updated_lockfile(updated_manifests)
80
+ lockfile = get_original_file(LOCKFILE_FILENAME)
81
+ manifest = get_original_file(MANIFEST_FILENAME)
82
+ return unless lockfile && manifest
83
+
84
+ manifest_content = updated_manifests.find { |f| f.name == manifest.name }&.content || manifest.content
85
+ content = LockfileUpdater.new(
86
+ dependencies: dependencies,
87
+ lockfile: lockfile,
88
+ manifest_content: T.must(manifest_content),
89
+ credentials: credentials,
90
+ repo_contents_path: repo_contents_path
91
+ ).updated_lockfile_content
92
+ return if content == lockfile.content
93
+
94
+ updated_file(file: lockfile, content: content)
95
+ end
96
+
62
97
  sig { params(file: Dependabot::DependencyFile).returns(String) }
63
98
  def updated_manifest_content(file)
64
99
  original_content = T.must(file.content)
@@ -125,11 +160,32 @@ module Dependabot
125
160
  return unless original.include?(declaration)
126
161
 
127
162
  new_declaration = declaration.sub(ref_at_end, "##{new_ref}\\1")
128
- Substitution.new(
163
+ substitution = Substitution.new(
129
164
  start_offset: start_offset,
130
165
  end_offset: end_offset,
131
166
  text: original.sub(declaration, new_declaration)
132
167
  )
168
+ with_release_tag_comment(content, substitution, new_req.metadata_string("release_tag"))
169
+ end
170
+
171
+ # Like `apm update`, annotates a bumped SHA pin with the release it now
172
+ # points at, replacing any comment already on the line. The comment is
173
+ # only written when nothing but whitespace or a comment follows the entry
174
+ # on its line, so entries in a flow sequence are left uncommented.
175
+ sig do
176
+ params(content: String, substitution: Substitution, release_tag: T.nilable(String)).returns(Substitution)
177
+ end
178
+ def with_release_tag_comment(content, substitution, release_tag)
179
+ return substitution unless release_tag
180
+
181
+ line_remainder = content[substitution.end_offset..]&.match(LINE_REMAINDER_REGEX)
182
+ return substitution unless line_remainder
183
+
184
+ Substitution.new(
185
+ start_offset: substitution.start_offset,
186
+ end_offset: substitution.end_offset + line_remainder[0].to_s.length,
187
+ text: "#{substitution.text} # #{release_tag}"
188
+ )
133
189
  end
134
190
 
135
191
  # Applies the substitutions from the end of the file backwards. Rewriting in
@@ -49,8 +49,63 @@ module Dependabot
49
49
  to_local_tag(max_version_tag)
50
50
  end
51
51
 
52
+ # The release a commit-SHA pin should move to, following `apm update`:
53
+ # the highest non-prerelease version tag in any of APM's tag forms
54
+ # (plain or package-scoped), considering only annotated tags. Lightweight
55
+ # tags and branches are never used, since they are not deliberate
56
+ # releases. Ignore conditions and cooldown apply as for tag pins.
57
+ #
58
+ # APM moves a SHA pin to that release even when the pinned commit is
59
+ # newer. When the pinned commit is itself a tagged release, Dependabot
60
+ # additionally requires a strictly higher version so it never proposes a
61
+ # downgrade.
62
+ sig do
63
+ params(cooldown_options: T.nilable(Dependabot::Package::ReleaseCooldownOptions))
64
+ .returns(T.nilable(Dependabot::GitTagDetails))
65
+ end
66
+ def latest_annotated_release_tag(cooldown_options = nil)
67
+ candidates = allowed_versions(local_tags, filter_by_prefix: false).select { |tag| annotated_release?(tag) }
68
+ pinned_version = current_version
69
+ candidates = candidates.select { |tag| version_from_tag(tag) > pinned_version } if pinned_version
70
+
71
+ filtered = apply_cooldown(candidates, cooldown_options)
72
+ return if filtered.nil?
73
+
74
+ max_local_tag(filtered)
75
+ end
76
+
77
+ # A SHA pin carries no version of its own, so the inherited lookup (which
78
+ # reads `dependency.version`) yields nil and cooldown would fall back to
79
+ # `default_days` for every candidate. When the pinned commit is a tagged
80
+ # release, use that version so SemVer-specific cooldown days apply.
81
+ sig { override.returns(T.nilable(Gem::Version)) }
82
+ def current_version
83
+ super || pinned_commit_version
84
+ end
85
+
52
86
  private
53
87
 
88
+ # An annotated tag is advertised as a tag object whose peeled (`^{}`)
89
+ # commit differs from it; a lightweight tag points at the commit directly.
90
+ sig { params(tag: Dependabot::GitRef).returns(T::Boolean) }
91
+ def annotated_release?(tag)
92
+ return false if tag.ref_sha.nil? || tag.ref_sha == tag.commit_sha
93
+
94
+ !version_from_tag(tag).prerelease?
95
+ end
96
+
97
+ # The highest version tagged on the pinned commit, if any.
98
+ sig { returns(T.nilable(Gem::Version)) }
99
+ def pinned_commit_version
100
+ pinned_sha = ref&.downcase
101
+ return unless pinned_sha
102
+
103
+ local_tags
104
+ .select { |tag| tag.commit_sha == pinned_sha && version_tag?(tag.name) }
105
+ .map { |tag| version_from_tag(tag) }
106
+ .max
107
+ end
108
+
54
109
  # Recognise a tag as a version only when it is a valid APM SemVer ref
55
110
  # (optionally package-scoped), so non-SemVer tags (e.g. `v1.2.3.4`) are
56
111
  # dropped before they can raise an ArgumentError, while build-metadata
@@ -0,0 +1,93 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "yaml"
6
+
7
+ require "dependabot/dependency_file"
8
+ require "dependabot/errors"
9
+ require "dependabot/apm/package_specifier"
10
+
11
+ module Dependabot
12
+ module Apm
13
+ # A parsed `apm.lock.yaml`, with its `dependencies` entries addressable by
14
+ # APM's package identity (see PackageSpecifier#lockfile_key).
15
+ class Lockfile
16
+ extend T::Sig
17
+
18
+ sig { params(file: Dependabot::DependencyFile).void }
19
+ def initialize(file)
20
+ @document = T.let(parse(file), T::Hash[Object, Object])
21
+ end
22
+
23
+ # The commit the lockfile pins for `key`, provided it was resolved from
24
+ # `ref`. A lock entry resolved from a different ref is stale (APM
25
+ # re-resolves it on the next run), so it is not reported as the
26
+ # dependency's current commit.
27
+ sig { params(key: String, ref: String).returns(T.nilable(String)) }
28
+ def resolved_commit(key, ref:)
29
+ entry = entry_for(key)
30
+ return unless entry && entry["resolved_ref"] == ref
31
+
32
+ commit = entry["resolved_commit"]
33
+ commit.is_a?(String) && !commit.empty? ? commit : nil
34
+ end
35
+
36
+ # Moves the entry for `key` to `commit`. Its `content_hash` describes the
37
+ # previous commit's contents, and APM fails a hash mismatch as a potential
38
+ # supply-chain attack, so it is dropped for APM to recompute. The rest of
39
+ # the entry (e.g. `deployed_files`) is kept.
40
+ sig { params(key: String, commit: String).void }
41
+ def pin_commit(key, commit)
42
+ entry = entry_for(key)
43
+ return unless entry
44
+
45
+ entry["resolved_commit"] = commit
46
+ entry.delete("content_hash")
47
+ end
48
+
49
+ sig { returns(String) }
50
+ def to_yaml
51
+ @document.to_yaml
52
+ end
53
+
54
+ private
55
+
56
+ sig { params(key: String).returns(T.nilable(T::Hash[Object, Object])) }
57
+ def entry_for(key)
58
+ entries = @document["dependencies"]
59
+ return unless entries.is_a?(Array)
60
+
61
+ entries.each do |entry|
62
+ return entry if entry.is_a?(Hash) && entry_key(entry) == key
63
+ end
64
+ nil
65
+ end
66
+
67
+ sig { params(entry: T::Hash[Object, Object]).returns(T.nilable(String)) }
68
+ def entry_key(entry)
69
+ repo_url = entry["repo_url"]
70
+ return unless repo_url.is_a?(String)
71
+
72
+ host = entry["host"]
73
+ virtual_path = entry["virtual_path"]
74
+ PackageSpecifier.lockfile_key(
75
+ host: host.is_a?(String) && !host.empty? ? host : PackageSpecifier::DEFAULT_HOST,
76
+ repo_url: repo_url,
77
+ virtual_path: virtual_path.is_a?(String) && !virtual_path.empty? ? virtual_path : nil
78
+ )
79
+ end
80
+
81
+ sig { params(file: Dependabot::DependencyFile).returns(T::Hash[Object, Object]) }
82
+ def parse(file)
83
+ parsed = YAML.safe_load(T.must(file.content))
84
+ parsed = {} if parsed.nil?
85
+ raise Dependabot::DependencyFileNotParseable, file.path unless parsed.is_a?(Hash)
86
+
87
+ parsed
88
+ rescue Psych::SyntaxError, Psych::DisallowedClass, Psych::BadAlias
89
+ raise Dependabot::DependencyFileNotParseable, file.path
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,54 @@
1
+ # typed: strong
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+ require "sorbet-runtime"
6
+
7
+ require "dependabot/shared_helpers"
8
+
9
+ module Dependabot
10
+ module Apm
11
+ module NativeHelpers
12
+ extend T::Sig
13
+
14
+ # APM renders its output through a terminal-width-aware console, which
15
+ # wraps long error messages mid-sentence. A wide, colourless console keeps
16
+ # each message on one line so errors can be matched reliably.
17
+ COMMAND_ENV = T.let(
18
+ {
19
+ "COLUMNS" => "1000",
20
+ "NO_COLOR" => "1",
21
+ "GIT_TERMINAL_PROMPT" => "0"
22
+ }.freeze,
23
+ T::Hash[String, String]
24
+ )
25
+
26
+ sig { returns(String) }
27
+ def self.apm_path
28
+ clean_path(File.join(native_helpers_root, "apm/bin/apm"))
29
+ end
30
+
31
+ # Runs an APM CLI command (e.g. `lock`) in the current directory, raising
32
+ # SharedHelpers::HelperSubprocessFailed with APM's output on failure.
33
+ sig { params(command: String).returns(String) }
34
+ def self.run_apm_command(command)
35
+ SharedHelpers.run_shell_command(
36
+ "#{apm_path} #{command}",
37
+ env: COMMAND_ENV,
38
+ fingerprint: "apm #{command}"
39
+ )
40
+ end
41
+
42
+ sig { returns(String) }
43
+ def self.native_helpers_root
44
+ default_path = File.join(__dir__, "../../../helpers/install-dir")
45
+ ENV.fetch("DEPENDABOT_NATIVE_HELPERS_PATH", default_path)
46
+ end
47
+
48
+ sig { params(path: String).returns(String) }
49
+ def self.clean_path(path)
50
+ Pathname.new(path).cleanpath.to_path
51
+ end
52
+ end
53
+ end
54
+ end
@@ -37,7 +37,9 @@ module Dependabot
37
37
  # path is treated as the repository so nested subgroups resolve to the
38
38
  # correct remote; virtual packages there are out of scope for v1, as is
39
39
  # self-hosted GHES on an arbitrary hostname that cannot be recognised from
40
- # the host alone.
40
+ # the host alone. As in APM, explicit clone URLs never carry a virtual path,
41
+ # so a URL with segments beyond `owner/repo` on a GitHub-family host
42
+ # resolves to `nil`.
41
43
  #
42
44
  # Azure DevOps hosts (`dev.azure.com`, `ssh.dev.azure.com` and legacy
43
45
  # `*.visualstudio.com`) expose repositories at `org/project/_git/repo`, a
@@ -52,6 +54,12 @@ module Dependabot
52
54
 
53
55
  DEFAULT_HOST = "github.com"
54
56
 
57
+ # APM only moves refs that are full commit SHAs (matched
58
+ # case-insensitively). Abbreviated SHAs are left alone, since they cannot
59
+ # be told apart from a branch name reliably.
60
+ FULL_SHA_REGEX = /\A[0-9a-f]{40}\z/i
61
+ ABBREVIATED_SHA_REGEX = /\A[0-9a-f]{7,39}\z/i
62
+
55
63
  # Azure DevOps Services hosts. Their repositories live at
56
64
  # `org/project/_git/repo`, a structure this generic owner/repo builder
57
65
  # cannot express, so ADO entries are out of scope for v1 (see README) and
@@ -98,6 +106,7 @@ module Dependabot
98
106
  spec, _, ref = entry.partition("#")
99
107
  host, path = split_host_and_path(spec, default_host)
100
108
  return nil unless path
109
+ return nil if explicit_git_form?(spec) && embedded_sub_path?(host, path)
101
110
 
102
111
  build(host: host, path: path, ref: ref, default_host: default_host)
103
112
  end
@@ -233,6 +242,24 @@ module Dependabot
233
242
  T.must(match[:port]).to_i == 443 ? T.must(match[:hostname]) : host
234
243
  end
235
244
 
245
+ sig { params(spec: String).returns(T::Boolean) }
246
+ def self.explicit_git_form?(spec)
247
+ spec.match?(SCP_STYLE) || spec.match?(URL_STYLE)
248
+ end
249
+
250
+ # APM never infers a virtual package from an explicit clone URL: virtual
251
+ # packages must use the shorthand or the object form's `path:` key. A
252
+ # GitHub-family URL with segments beyond `owner/repo`, or any URL with a
253
+ # path after a `.git` segment, is rejected by `apm` (or names a repository
254
+ # that cannot exist), so such entries are skipped rather than updated.
255
+ sig { params(host: String, path: String).returns(T::Boolean) }
256
+ def self.embedded_sub_path?(host, path)
257
+ segments = path.split("/").reject(&:empty?)
258
+ return true if segments[0...-1]&.any? { |segment| segment.end_with?(".git") }
259
+
260
+ github_family?(host.downcase) && segments.length > 2
261
+ end
262
+
236
263
  sig { params(entry: String).returns(T::Boolean) }
237
264
  def self.local_path?(entry)
238
265
  entry.start_with?("./", "../", "/", "~/", ".\\", "..\\", "~\\") ||
@@ -342,6 +369,35 @@ module Dependabot
342
369
  virtual_path = sub_path
343
370
  virtual_path ? "#{repo_name}/#{virtual_path}" : repo_name
344
371
  end
372
+
373
+ # The key APM uses to identify this package in `apm.lock.yaml`. Unlike
374
+ # `name`, it ignores the manifest's `default_host` and any port: APM keys
375
+ # entries by `repo_url`, prefixed by the host for anything other than
376
+ # github.com, plus the virtual path for virtual packages. The virtual path
377
+ # is joined with `//`, which never appears in a repository path, so a
378
+ # virtual package only matches a lock entry with the same repository: a
379
+ # GitLab `acme/prompts/skills/review` repository doesn't match the
380
+ # `acme/prompts` repository's `skills/review` package.
381
+ sig { returns(String) }
382
+ def lockfile_key
383
+ self.class.lockfile_key(
384
+ host: self.class.hostname_without_port(host),
385
+ repo_url: "#{owner}/#{repo}",
386
+ virtual_path: sub_path
387
+ )
388
+ end
389
+
390
+ # Builds a lockfile key from a lock entry's coordinates (or a manifest
391
+ # entry's, via #lockfile_key). GitHub-family identifiers are
392
+ # case-insensitive, so they are lowercased; other hosts keep their casing,
393
+ # as APM does.
394
+ sig { params(host: String, repo_url: String, virtual_path: T.nilable(String)).returns(String) }
395
+ def self.lockfile_key(host:, repo_url:, virtual_path: nil)
396
+ hostname = host.downcase
397
+ repo_url = repo_url.downcase if github_family?(hostname)
398
+ key = hostname == DEFAULT_HOST ? repo_url : "#{hostname}/#{repo_url}"
399
+ virtual_path ? "#{key}//#{virtual_path}" : key
400
+ end
345
401
  end
346
402
  end
347
403
  end
@@ -9,6 +9,7 @@ require "dependabot/git_tag_details"
9
9
  require "dependabot/update_checkers"
10
10
  require "dependabot/update_checkers/base"
11
11
  require "dependabot/apm/git_commit_checker"
12
+ require "dependabot/apm/package_specifier"
12
13
  require "dependabot/apm/requirement"
13
14
  require "dependabot/apm/version"
14
15
 
@@ -34,8 +35,12 @@ module Dependabot
34
35
 
35
36
  sig { override.returns(T.nilable(T.any(String, Gem::Version))) }
36
37
  def latest_resolvable_version_with_no_unlock
37
- # Updating an APM dependency always rewrites its manifest requirement,
38
- # so there is no newer version reachable "without unlocking".
38
+ # A branch pin moves to the branch's new head commit through the
39
+ # lockfile alone, without touching the manifest requirement.
40
+ return latest_version if branch_pinned?
41
+
42
+ # Every other APM update rewrites its manifest requirement, so there is
43
+ # no newer version reachable "without unlocking".
39
44
  dependency.version
40
45
  end
41
46
 
@@ -59,6 +64,7 @@ module Dependabot
59
64
  sig { override.returns(T::Array[Dependabot::DependencyRequirement]) }
60
65
  def updated_requirements
61
66
  return dependency.requirements unless git_commit_checker.git_dependency?
67
+ return updated_sha_pin_requirements if sha_pinned?
62
68
 
63
69
  dependency.requirements.map do |req|
64
70
  source = req.source_hash
@@ -98,6 +104,25 @@ module Dependabot
98
104
  raise NotImplementedError
99
105
  end
100
106
 
107
+ # The inherited check compares only the merged `dependency.version` (the
108
+ # first declaration's SHA) with the latest release commit. When that first
109
+ # declaration is already at the release but a later one is pinned to an
110
+ # older SHA, both resolve to the same commit and the outdated declaration
111
+ # would never be moved, so judge SHA pins by their individual declarations.
112
+ sig { returns(T::Boolean) }
113
+ def sha1_version_up_to_date?
114
+ return super unless sha_pinned?
115
+
116
+ updated_sha_pin_requirements == dependency.requirements
117
+ end
118
+
119
+ sig { returns(T::Boolean) }
120
+ def preferred_version_resolvable_with_unlock?
121
+ return super unless sha_pinned?
122
+
123
+ !sha1_version_up_to_date?
124
+ end
125
+
101
126
  # `latest_version` deliberately reports the MAX tag reachable by ANY family
102
127
  # so the base `can_update?` gate still fires when only a higher family can
103
128
  # move. But `DependencySet` defines a merged dependency's version as its
@@ -111,8 +136,12 @@ module Dependabot
111
136
  # version separate.
112
137
  sig { returns(Dependabot::Dependency) }
113
138
  def updated_dependency_with_own_req_unlock
139
+ # A branch pin is versioned by commit, so the base behaviour of
140
+ # reporting the latest commit as the new version applies to it.
141
+ return super unless tag_pinned? || sha_pinned?
142
+
114
143
  new_requirements = updated_requirements
115
- new_version = pinned_versions(new_requirements).min
144
+ new_version = sha_pinned? ? merged_sha_version(new_requirements) : pinned_versions(new_requirements).min
116
145
 
117
146
  Dependabot::Dependency.new(
118
147
  name: dependency.name,
@@ -126,13 +155,21 @@ module Dependabot
126
155
  )
127
156
  end
128
157
 
158
+ # DependencySet keeps the first declaration's version when merging SHA
159
+ # pins, so that's the version the updated manifest parses to.
160
+ sig { params(requirements: T::Array[Dependabot::DependencyRequirement]).returns(T.nilable(String)) }
161
+ def merged_sha_version(requirements)
162
+ requirements.first&.source_string("ref")&.downcase
163
+ end
164
+
129
165
  sig { returns(T.nilable(T.any(String, Gem::Version))) }
130
166
  def fetch_latest_version
131
167
  return dependency.version unless git_commit_checker.git_dependency?
168
+ return latest_branch_commit if branch_pinned?
169
+ return latest_sha_pin_commit if sha_pinned?
132
170
 
133
- # Only entries pinned to a semver tag are bumped in the manifest.
134
- # Branch- and SHA-pinned entries are resolved via the lockfile, which
135
- # APM regenerates itself, so Dependabot leaves them untouched.
171
+ # Unpinned entries are never parsed, so any other dependency is
172
+ # expected to be pinned to a version tag.
136
173
  return dependency.version unless git_commit_checker.pinned_ref_looks_like_version?
137
174
 
138
175
  # A merged dependency can hold several ref families (e.g. `review--v*`
@@ -146,6 +183,74 @@ module Dependabot
146
183
  end.max || dependency.version
147
184
  end
148
185
 
186
+ sig { returns(T::Boolean) }
187
+ def branch_pinned?
188
+ dependency.requirements.any? { |req| req.source_string("branch") }
189
+ end
190
+
191
+ sig { returns(T::Boolean) }
192
+ def sha_pinned?
193
+ refs = dependency.requirements.map { |req| req.source_string("ref") }
194
+ refs.any? && refs.all? { |ref| ref&.match?(PackageSpecifier::FULL_SHA_REGEX) }
195
+ end
196
+
197
+ sig { returns(T::Boolean) }
198
+ def tag_pinned?
199
+ !branch_pinned? && !sha_pinned?
200
+ end
201
+
202
+ # A branch pin is versioned by the commit the lockfile resolved, and moves
203
+ # to the branch's current head commit.
204
+ sig { returns(T.nilable(String)) }
205
+ def latest_branch_commit
206
+ git_commit_checker.head_commit_for_current_branch
207
+ end
208
+
209
+ # The commit of the release each SHA-pinned declaration moves to. All of a
210
+ # dependency's declarations share one repository, so they resolve to the
211
+ # same release unless a declaration is already pinned beyond it.
212
+ sig { returns(T.nilable(String)) }
213
+ def latest_sha_pin_commit
214
+ dependency.requirements.filter_map { |req| sha_pin_release(req)&.commit_sha }.first || dependency.version
215
+ end
216
+
217
+ # Moves each SHA pin to its release commit, recording the release tag so
218
+ # the manifest can be annotated with it as `apm update` does.
219
+ sig { returns(T::Array[Dependabot::DependencyRequirement]) }
220
+ def updated_sha_pin_requirements
221
+ dependency.requirements.map do |req|
222
+ release = sha_pin_release(req)
223
+ source = req.source_hash
224
+ next req unless release && source
225
+
226
+ Dependabot::DependencyRequirement.create(
227
+ req.merge(
228
+ source: source.merge(ref: release.commit_sha),
229
+ metadata: (req.metadata || {}).merge(release_tag: release.tag)
230
+ )
231
+ )
232
+ end
233
+ end
234
+
235
+ sig { params(req: Dependabot::DependencyRequirement).returns(T.nilable(Dependabot::GitTagDetails)) }
236
+ def sha_pin_release(req)
237
+ source = req.source_hash
238
+ pinned_sha = req.source_string("ref")&.downcase
239
+ return unless source && pinned_sha&.match?(PackageSpecifier::FULL_SHA_REGEX)
240
+
241
+ releases = sha_pin_releases
242
+ return releases[pinned_sha] if releases.key?(pinned_sha)
243
+
244
+ release = git_commit_checker_for(source).latest_annotated_release_tag(update_cooldown)
245
+ release = nil if release&.commit_sha.nil? || release&.commit_sha == pinned_sha
246
+ releases[pinned_sha] = release
247
+ end
248
+
249
+ sig { returns(T::Hash[String, T.nilable(Dependabot::GitTagDetails)]) }
250
+ def sha_pin_releases
251
+ @sha_pin_releases ||= T.let({}, T.nilable(T::Hash[String, T.nilable(Dependabot::GitTagDetails)]))
252
+ end
253
+
149
254
  # The tag a single requirement should move to: the latest tag resolved
150
255
  # within that requirement's own ref family (via a checker scoped to its
151
256
  # ref). Returns nil when there is no strictly-higher tag, so a requirement
@@ -235,7 +340,7 @@ module Dependabot
235
340
  params(
236
341
  source: Dependabot::DependencyRequirement::ObjectHash,
237
342
  ref_version: T.nilable(String)
238
- ).returns(Dependabot::GitCommitChecker)
343
+ ).returns(Dependabot::Apm::GitCommitChecker)
239
344
  end
240
345
  def git_commit_checker_for(source, ref_version = nil)
241
346
  scoped_dependency =
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dependabot-apm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.400.0
4
+ version: 0.402.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dependabot
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - '='
17
17
  - !ruby/object:Gem::Version
18
- version: 0.400.0
18
+ version: 0.402.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - '='
24
24
  - !ruby/object:Gem::Version
25
- version: 0.400.0
25
+ version: 0.402.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: debug
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -245,8 +245,11 @@ files:
245
245
  - lib/dependabot/apm/file_fetcher.rb
246
246
  - lib/dependabot/apm/file_parser.rb
247
247
  - lib/dependabot/apm/file_updater.rb
248
+ - lib/dependabot/apm/file_updater/lockfile_updater.rb
248
249
  - lib/dependabot/apm/git_commit_checker.rb
250
+ - lib/dependabot/apm/lockfile.rb
249
251
  - lib/dependabot/apm/metadata_finder.rb
252
+ - lib/dependabot/apm/native_helpers.rb
250
253
  - lib/dependabot/apm/package_manager.rb
251
254
  - lib/dependabot/apm/package_specifier.rb
252
255
  - lib/dependabot/apm/requirement.rb
@@ -257,7 +260,7 @@ licenses:
257
260
  - MIT
258
261
  metadata:
259
262
  bug_tracker_uri: https://github.com/dependabot/dependabot-core/issues
260
- changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.400.0
263
+ changelog_uri: https://github.com/dependabot/dependabot-core/releases/tag/v0.402.0
261
264
  rdoc_options: []
262
265
  require_paths:
263
266
  - lib