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 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