dependabot-conda 0.349.0 → 0.351.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,428 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require "sorbet-runtime"
5
+ require "dependabot/conda/requirement"
6
+ require "dependabot/conda/update_checker"
7
+ require "dependabot/conda/version"
8
+ require "dependabot/requirements_update_strategy"
9
+
10
+ module Dependabot
11
+ module Conda
12
+ class UpdateChecker < Dependabot::UpdateCheckers::Base
13
+ class RequirementsUpdater
14
+ extend T::Sig
15
+
16
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
17
+ attr_reader :requirements
18
+
19
+ sig { returns(Dependabot::RequirementsUpdateStrategy) }
20
+ attr_reader :update_strategy
21
+
22
+ sig { returns(T.nilable(Dependabot::Conda::Version)) }
23
+ attr_reader :latest_resolvable_version
24
+
25
+ sig do
26
+ params(
27
+ requirements: T::Array[T::Hash[Symbol, T.untyped]],
28
+ update_strategy: Dependabot::RequirementsUpdateStrategy,
29
+ latest_resolvable_version: T.nilable(String)
30
+ ).void
31
+ end
32
+ def initialize(requirements:, update_strategy:, latest_resolvable_version:)
33
+ @requirements = requirements
34
+ @update_strategy = update_strategy
35
+ @latest_resolvable_version = T.let(
36
+ (Conda::Version.new(latest_resolvable_version) if latest_resolvable_version),
37
+ T.nilable(Dependabot::Conda::Version)
38
+ )
39
+ end
40
+
41
+ sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) }
42
+ def updated_requirements
43
+ return requirements if update_strategy.lockfile_only?
44
+ return requirements unless latest_resolvable_version
45
+
46
+ requirements.map do |req|
47
+ case update_strategy
48
+ when RequirementsUpdateStrategy::WidenRanges
49
+ widen_requirement(req)
50
+ when RequirementsUpdateStrategy::BumpVersions
51
+ update_requirement(req)
52
+ when RequirementsUpdateStrategy::BumpVersionsIfNecessary
53
+ update_requirement_if_needed(req)
54
+ else
55
+ raise "Unexpected update strategy: #{update_strategy}"
56
+ end
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
63
+ def update_requirement_if_needed(req)
64
+ return req if new_version_satisfies?(req)
65
+
66
+ update_requirement(req)
67
+ end
68
+
69
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
70
+ def update_requirement(req)
71
+ return req unless req[:requirement]
72
+ return req if ["", "*"].include?(req[:requirement])
73
+
74
+ requirement_strings = req[:requirement].split(",").map(&:strip)
75
+ new_req = calculate_updated_requirement(req, requirement_strings)
76
+
77
+ new_req == :unfixable ? req.merge(requirement: :unfixable) : req.merge(requirement: new_req)
78
+ end
79
+
80
+ sig { params(req: T::Hash[Symbol, T.untyped], requirement_strings: T::Array[String]).returns(T.any(String, Symbol)) }
81
+ def calculate_updated_requirement(req, requirement_strings)
82
+ # Step 1: Check for equality match first (e.g., "==1.21.0" or bare "1.21.0")
83
+ return handle_equality_match(requirement_strings) if equality_match?(requirement_strings)
84
+
85
+ # Step 2: Handle range requirements (e.g., ">=3.10,<3.12")
86
+ return handle_range_requirement(req, requirement_strings) if requirement_strings.length > 1
87
+
88
+ # Step 3: Handle single constraint (e.g., ">=3.10")
89
+ handle_single_constraint(req)
90
+ end
91
+
92
+ sig { params(requirement_strings: T::Array[String]).returns(T::Boolean) }
93
+ def equality_match?(requirement_strings)
94
+ requirement_strings.any? { |r| r.match?(/^[=\d]/) }
95
+ end
96
+
97
+ sig { params(requirement_strings: T::Array[String]).returns(T.any(String, Symbol)) }
98
+ def handle_equality_match(requirement_strings)
99
+ find_and_update_equality_match(requirement_strings, latest_resolvable_version)
100
+ end
101
+
102
+ sig { params(req: T::Hash[Symbol, T.untyped], requirement_strings: T::Array[String]).returns(T.any(String, Symbol)) }
103
+ def handle_range_requirement(req, requirement_strings)
104
+ # Only skip update if using BumpVersionsIfNecessary strategy and version already satisfies
105
+ # For BumpVersions strategy, always update to the new version
106
+ if update_strategy == RequirementsUpdateStrategy::BumpVersionsIfNecessary &&
107
+ new_version_satisfies?(req)
108
+ return req[:requirement]
109
+ end
110
+
111
+ update_requirements_range(requirement_strings)
112
+ end
113
+
114
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T.any(String, Symbol)) }
115
+ def handle_single_constraint(req)
116
+ # Only skip update if using BumpVersionsIfNecessary strategy and version already satisfies
117
+ # For BumpVersions strategy, always update to the new version
118
+ if update_strategy == RequirementsUpdateStrategy::BumpVersionsIfNecessary &&
119
+ new_version_satisfies?(req)
120
+ return req[:requirement]
121
+ end
122
+
123
+ bump_version_string(req[:requirement], T.must(latest_resolvable_version).to_s)
124
+ end
125
+
126
+ sig do
127
+ params(
128
+ requirement_strings: T::Array[String],
129
+ latest_version: T.nilable(Conda::Version)
130
+ ).returns(T.any(String, Symbol))
131
+ end
132
+ def find_and_update_equality_match(requirement_strings, latest_version)
133
+ return :unfixable unless latest_version
134
+
135
+ current_requirement = requirement_strings.join(",")
136
+
137
+ # If dealing with a bare version number, treat it as exact match
138
+ if requirement_strings.length == 1 && T.must(requirement_strings.first).match?(/^\d/)
139
+ return "==#{latest_version}"
140
+ end
141
+
142
+ # Find the equality constraint (= or ==)
143
+ equality_req = requirement_strings.find { |r| r.match?(/^=+/) }
144
+ return current_requirement unless equality_req
145
+
146
+ # Extract version from equality constraint
147
+ version_string = equality_req.sub(/^=+\s*/, "")
148
+
149
+ # Preserve wildcard precision if present
150
+ return preserve_wildcard_precision(equality_req, latest_version.to_s) if version_string.include?("*")
151
+
152
+ # Determine operator (= or ==)
153
+ operator = equality_req.match?(/^==/) ? "==" : "="
154
+
155
+ # Standard equality update
156
+ "#{operator}#{latest_version}"
157
+ end
158
+
159
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
160
+ def widen_requirement(req)
161
+ return req unless req[:requirement]
162
+ return req if ["", "*"].include?(req[:requirement])
163
+
164
+ # For WidenRanges, always widen to ensure proper upper bounds
165
+ # Don't return early even if version satisfies - we want to add/update bounds
166
+ new_requirement = widen_requirement_string(req[:requirement])
167
+ req.merge(requirement: new_requirement)
168
+ end
169
+
170
+ sig { params(req: T::Hash[Symbol, T.untyped]).returns(T::Boolean) }
171
+ def new_version_satisfies?(req)
172
+ return false unless req[:requirement]
173
+
174
+ Conda::Requirement
175
+ .requirements_array(req[:requirement])
176
+ .all? { |r| r.satisfied_by?(T.must(latest_resolvable_version)) }
177
+ end
178
+
179
+ sig { params(req_string: String, new_version: String).returns(T.any(String, Symbol)) }
180
+ def bump_version_string(req_string, new_version)
181
+ # Strip whitespace for matching but preserve operator
182
+ stripped = req_string.strip
183
+
184
+ # Parse the current requirement to preserve the operator type
185
+ case stripped
186
+ when /^=\s*([0-9])/
187
+ # Conda exact version: =1.26 or =1.21.*
188
+ if stripped.include?("*")
189
+ # Wildcard: =1.21.* → =2.3.* (preserve wildcard pattern at new major.minor)
190
+ preserve_wildcard_precision(stripped, new_version)
191
+ else
192
+ # Exact: =1.26 → =2.3.4
193
+ "=#{new_version}"
194
+ end
195
+ when /^==\s*([0-9])/
196
+ # Pip exact version: ==1.26 or ==1.21.*
197
+ if stripped.include?("*")
198
+ preserve_wildcard_precision(stripped, new_version)
199
+ else
200
+ "==#{new_version}"
201
+ end
202
+ when /^>=\s*([0-9])/
203
+ # Range constraint: >=1.26 → >=2.3.4
204
+ # Check if version is too high (unfixable)
205
+ current_version_str = stripped[/>=\s*([\d.]+)/, 1]
206
+ if current_version_str && Conda::Version.new(current_version_str) > Conda::Version.new(new_version)
207
+ return :unfixable
208
+ end
209
+
210
+ ">=#{new_version}"
211
+ when /^>\s*([0-9])/
212
+ # Greater than: >1.26 → >2.3.4
213
+ ">#{new_version}"
214
+ when /^~=\s*([0-9])/
215
+ # Compatible release: ~=1.26 → ~=2.3.4
216
+ "~=#{new_version}"
217
+ when /^<=/, /^</, /^!=/
218
+ # Upper bound or not-equal constraints: keep unchanged
219
+ req_string
220
+ else
221
+ # Default to conda-style equality
222
+ "=#{new_version}"
223
+ end
224
+ end
225
+
226
+ sig { params(req_string: String, new_version: String).returns(String) }
227
+ def preserve_wildcard_precision(req_string, new_version)
228
+ # Count asterisks in original to preserve precision
229
+ # =1.21.* → =2.3.* (preserve major.minor.*)
230
+ # =1.* → =2.* (preserve major.*)
231
+
232
+ operator = req_string[/^[=~><!]+/] || "="
233
+ wildcard_parts = req_string.scan(/\d+|\*/)
234
+ new_parts = new_version.split(".")
235
+
236
+ # Build new requirement with same wildcard pattern
237
+ result_parts = []
238
+ wildcard_parts.each_with_index do |part, idx|
239
+ if part == "*"
240
+ result_parts << "*"
241
+ break # Stop after first wildcard
242
+ else
243
+ result_parts << (new_parts[idx] || "0")
244
+ end
245
+ end
246
+
247
+ "#{operator}#{result_parts.join('.')}"
248
+ end
249
+
250
+ sig { params(req_string: String).returns(T.any(String, Symbol)) }
251
+ def widen_requirement_string(req_string)
252
+ # Convert wildcards and exact matches to ranges
253
+ # Order matters: check >= before = to avoid partial matches
254
+
255
+ if req_string.include?("*")
256
+ # Wildcard: =1.21.* → >=1.21,<3.0 (widen to major version range)
257
+ convert_wildcard_to_range(req_string)
258
+ elsif req_string.match?(/^>=/)
259
+ # Already a range (>=), update or add upper bound
260
+ result = update_range_upper_bound(req_string)
261
+ return result if result == :unfixable
262
+
263
+ result
264
+ elsif req_string.match?(/^(==?)\s*\d/)
265
+ # Exact match: =1.26 or ==1.26 → >=1.26,<3.0
266
+ convert_exact_to_range(req_string)
267
+ elsif req_string.match?(/^~=/)
268
+ # Compatible release: ~=1.3.0 → >=1.3,<3.0
269
+ convert_compatible_to_range(req_string)
270
+ elsif req_string.match?(/^(<=|<|!=)/)
271
+ # Upper bound or not-equal constraints: keep unchanged
272
+ req_string
273
+ else
274
+ # Unknown format, bump version as fallback
275
+ bump_version_string(req_string, T.must(latest_resolvable_version).to_s)
276
+ end
277
+ end
278
+
279
+ sig { params(req_string: String).returns(String) }
280
+ def convert_wildcard_to_range(req_string)
281
+ # =1.21.* becomes >=1.21,<3.0 (or whatever major version latest is)
282
+ version_match = req_string.match(/(\d+(?:\.\d+)*)/)
283
+ return req_string unless version_match
284
+
285
+ lower_bound = version_match[1]
286
+ new_version = T.must(latest_resolvable_version)
287
+ upper_major = new_version.version_parts[0].to_i + 1
288
+
289
+ ">=#{lower_bound},<#{upper_major}.0"
290
+ end
291
+
292
+ sig { params(req_string: String).returns(String) }
293
+ def convert_exact_to_range(req_string)
294
+ # =1.26 becomes >=1.26,<3.0
295
+ version_match = req_string.match(/(\d+(?:\.\d+)*)/)
296
+ return req_string unless version_match
297
+
298
+ lower_bound = version_match[1]
299
+ new_version = T.must(latest_resolvable_version)
300
+ upper_major = new_version.version_parts[0].to_i + 1
301
+
302
+ ">=#{lower_bound},<#{upper_major}.0"
303
+ end
304
+
305
+ sig { params(req_string: String).returns(T.any(String, Symbol)) }
306
+ def update_range_upper_bound(req_string)
307
+ # >=1.26,<2.0 becomes >=1.26,<3.0
308
+ # Check if lower bound is too high (unfixable)
309
+ lower_bound_match = req_string.match(/>=\s*([\d.]+)/)
310
+ if lower_bound_match
311
+ lower_version = Conda::Version.new(lower_bound_match[1])
312
+ return :unfixable if lower_version > T.must(latest_resolvable_version)
313
+ end
314
+
315
+ new_version = T.must(latest_resolvable_version)
316
+ upper_major = new_version.version_parts[0].to_i + 1
317
+
318
+ if req_string.include?(",<")
319
+ # Replace upper bound
320
+ req_string.sub(/,<[\d.]+/, ",<#{upper_major}.0")
321
+ else
322
+ # Add upper bound
323
+ "#{req_string},<#{upper_major}.0"
324
+ end
325
+ end
326
+
327
+ sig { params(req_string: String).returns(String) }
328
+ def convert_compatible_to_range(req_string)
329
+ # ~=1.3.0 becomes >=1.3,<3.0
330
+ version_match = req_string.match(/~=\s*([\d.]+)/)
331
+ return req_string unless version_match
332
+
333
+ lower_bound = version_match[1]
334
+ # Extract major.minor for lower bound
335
+ parts = T.must(lower_bound).split(".")
336
+ lower_parts = parts.take(2)
337
+ lower_bound_simplified = lower_parts.join(".")
338
+
339
+ new_version = T.must(latest_resolvable_version)
340
+ upper_major = new_version.version_parts[0].to_i + 1
341
+
342
+ ">=#{lower_bound_simplified},<#{upper_major}.0"
343
+ end
344
+
345
+ sig { params(requirement_strings: T::Array[String]).returns(T.any(String, Symbol)) }
346
+ def update_requirements_range(requirement_strings)
347
+ # Handle comma-separated requirements like ">=3.10,<3.12"
348
+ # For BumpVersions strategy (matching Python's logic):
349
+ # - Keep constraints that already satisfy the new version
350
+ # - Update upper bounds (<, <=) that don't satisfy
351
+ # - Lower bounds (>=, >) that don't satisfy are UNFIXABLE
352
+
353
+ updated_parts = requirement_strings.map do |req_str|
354
+ stripped = req_str.strip
355
+
356
+ # Check if this individual constraint is satisfied by new version
357
+ if Conda::Requirement.requirements_array(stripped).any? { |r| r.satisfied_by?(T.must(latest_resolvable_version)) }
358
+ # Already satisfied - keep unchanged
359
+ stripped
360
+ elsif stripped.match?(/^</)
361
+ # Upper bound not satisfied - update to accommodate new version
362
+ update_upper_bound(stripped)
363
+ elsif stripped.match?(/^>=|^>/)
364
+ # Lower bound not satisfied - this is unfixable for BumpVersions
365
+ # (We don't lower the minimum version requirement)
366
+ return :unfixable
367
+ elsif stripped.match?(/^!=/)
368
+ # Exclusion not satisfied (new version equals excluded version) - unfixable
369
+ return :unfixable
370
+ else
371
+ # Unknown constraint - keep unchanged
372
+ stripped
373
+ end
374
+ end
375
+
376
+ updated_parts.join(",")
377
+ end
378
+
379
+ sig { params(upper_bound_str: String).returns(String) }
380
+ def update_upper_bound(upper_bound_str)
381
+ # Update upper bound to accommodate new version using Python's algorithm
382
+ new_version = T.must(latest_resolvable_version)
383
+
384
+ if upper_bound_str.start_with?("<=")
385
+ # <= constraint: update to new version exactly
386
+ "<=#{new_version}"
387
+ elsif upper_bound_str.start_with?("<")
388
+ # < constraint: calculate appropriate next version
389
+ # Extract current upper bound version
390
+ current_upper = upper_bound_str.sub(/^<\s*/, "")
391
+ updated_version = calculate_next_version_bound(current_upper, new_version)
392
+ "<#{updated_version}"
393
+ else
394
+ # Shouldn't reach here, but return unchanged
395
+ upper_bound_str
396
+ end
397
+ end
398
+
399
+ sig { params(current_upper: String, new_version: Conda::Version).returns(String) }
400
+ def calculate_next_version_bound(current_upper, new_version)
401
+ # Python's algorithm: find the rightmost non-zero segment in current upper bound
402
+ # and increment the corresponding segment in the new version
403
+ current_segments = current_upper.split(".").map(&:to_i)
404
+ new_segments = new_version.version_parts.map(&:to_i)
405
+
406
+ # Find the index of the rightmost non-zero segment in current upper bound
407
+ index_to_update = current_segments.map.with_index { |n, i| n.to_i.zero? ? 0 : i }.max || 0
408
+
409
+ # Ensure we don't go beyond the new version's segment count
410
+ index_to_update = [index_to_update, new_segments.count - 1].min
411
+
412
+ # Build new upper bound
413
+ result_segments = new_segments.map.with_index do |_, index|
414
+ if index < index_to_update
415
+ new_segments[index]
416
+ elsif index == index_to_update
417
+ T.must(new_segments[index]) + 1
418
+ else
419
+ 0
420
+ end
421
+ end
422
+
423
+ result_segments.join(".")
424
+ end
425
+ end
426
+ end
427
+ end
428
+ end
@@ -6,7 +6,7 @@ require "dependabot/update_checkers"
6
6
  require "dependabot/update_checkers/base"
7
7
  require "dependabot/conda/version"
8
8
  require "dependabot/conda/requirement"
9
- require "dependabot/conda/python_package_classifier"
9
+ require "dependabot/requirements_update_strategy"
10
10
 
11
11
  module Dependabot
12
12
  module Conda
@@ -50,28 +50,24 @@ module Dependabot
50
50
 
51
51
  sig { override.returns(T.nilable(T.any(String, Dependabot::Version))) }
52
52
  def latest_version
53
+ return nil if dependency.requirements.all? { |req| req[:requirement].nil? || req[:requirement] == "*" }
54
+
53
55
  @latest_version ||= fetch_latest_version
54
56
  end
55
57
 
56
58
  sig { override.returns(T.nilable(T.any(String, Dependabot::Version))) }
57
59
  def latest_resolvable_version_with_no_unlock
58
- # For now, same as latest_version since we're not doing full dependency resolution
59
60
  latest_version
60
61
  end
61
62
 
62
63
  sig { override.returns(T.nilable(T.any(String, Dependabot::Version))) }
63
64
  def latest_resolvable_version
64
- # For Phase 3, delegate to latest_version_finder
65
- # This will be enhanced with actual conda search and PyPI integration
66
65
  latest_version
67
66
  end
68
67
 
69
68
  sig { override.returns(T::Boolean) }
70
69
  def up_to_date?
71
70
  return true if latest_version.nil?
72
-
73
- # If dependency has no version (range constraint like >=2.0),
74
- # we can't determine if it's up-to-date, so assume it needs checking
75
71
  return false if dependency.version.nil?
76
72
 
77
73
  T.must(latest_version) <= Dependabot::Conda::Version.new(dependency.version)
@@ -79,8 +75,6 @@ module Dependabot
79
75
 
80
76
  sig { override.returns(T::Boolean) }
81
77
  def requirements_unlocked_or_can_be?
82
- # For conda, we don't have lock files, so requirements can always be updated
83
- # This is unlike other ecosystems that have lock files (package-lock.json, Pipfile.lock, etc.)
84
78
  true
85
79
  end
86
80
 
@@ -102,17 +96,18 @@ module Dependabot
102
96
 
103
97
  sig { override.returns(T::Array[T::Hash[Symbol, T.untyped]]) }
104
98
  def updated_requirements
105
- target_version = preferred_resolvable_version
106
- return dependency.requirements unless target_version
107
-
108
- dependency.requirements.map do |req|
109
- req.merge(
110
- requirement: update_requirement_string(
111
- req[:requirement] || "=#{dependency.version}",
112
- target_version.to_s
113
- )
114
- )
115
- end
99
+ RequirementsUpdater.new(
100
+ requirements: dependency.requirements,
101
+ update_strategy: requirements_update_strategy,
102
+ latest_resolvable_version: preferred_resolvable_version&.to_s
103
+ ).updated_requirements
104
+ end
105
+
106
+ sig { override.returns(Dependabot::RequirementsUpdateStrategy) }
107
+ def requirements_update_strategy
108
+ return @requirements_update_strategy if @requirements_update_strategy
109
+
110
+ RequirementsUpdateStrategy::BumpVersions
116
111
  end
117
112
 
118
113
  private
@@ -140,11 +135,8 @@ module Dependabot
140
135
 
141
136
  sig { returns(T.nilable(Dependabot::Version)) }
142
137
  def fetch_lowest_resolvable_security_fix_version
143
- # Delegate to latest_version_finder for security fix resolution
144
- # This leverages Python ecosystem's security advisory infrastructure
145
138
  fix_version = latest_version_finder.lowest_security_fix_version
146
139
 
147
- # If no security fix version is found, fall back to latest_resolvable_version
148
140
  if fix_version.nil?
149
141
  fallback = latest_resolvable_version
150
142
  return fallback.is_a?(String) ? Dependabot::Conda::Version.new(fallback) : fallback
@@ -155,7 +147,6 @@ module Dependabot
155
147
 
156
148
  sig { override.returns(T::Boolean) }
157
149
  def latest_version_resolvable_with_full_unlock?
158
- # No lock file support for Conda
159
150
  false
160
151
  end
161
152
 
@@ -163,49 +154,11 @@ module Dependabot
163
154
  def updated_dependencies_after_full_unlock
164
155
  raise NotImplementedError
165
156
  end
166
-
167
- sig { params(requirement_string: String, new_version: String).returns(String) }
168
- def update_requirement_string(requirement_string, new_version)
169
- # Parse the current requirement to preserve the operator type
170
- case requirement_string
171
- when /^=([0-9])/
172
- # Conda exact version: =1.26 -> =2.3.2
173
- "=#{new_version}"
174
- when /^==([0-9])/
175
- # Pip exact version: ==1.26 -> ==2.3.2
176
- "==#{new_version}"
177
- when /^>=([0-9])/
178
- # Range constraint: preserve as range but update to new version
179
- ">=#{new_version}"
180
- when /^>([0-9])/
181
- # Greater than: >1.26 -> >2.3.2
182
- ">#{new_version}"
183
- when /^<=([0-9])/
184
- # Less than or equal: keep as is (shouldn't be updated)
185
- requirement_string
186
- when /^<([0-9])/
187
- # Less than: keep as is (shouldn't be updated)
188
- requirement_string
189
- when /^!=([0-9])/
190
- # Not equal: keep as is
191
- requirement_string
192
- when /^~=([0-9])/
193
- # Compatible release: ~=1.26 -> ~=2.3.2
194
- "~=#{new_version}"
195
- else
196
- # Default to conda-style equality for unknown patterns
197
- "=#{new_version}"
198
- end
199
- end
200
-
201
- sig { params(package_name: String).returns(T::Boolean) }
202
- def python_package?(package_name)
203
- PythonPackageClassifier.python_package?(package_name)
204
- end
205
157
  end
206
158
  end
207
159
  end
208
160
 
209
161
  require_relative "update_checker/latest_version_finder"
162
+ require_relative "update_checker/requirements_updater"
210
163
 
211
164
  Dependabot::UpdateCheckers.register("conda", Dependabot::Conda::UpdateChecker)