refinement 0.6.1 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 56b3b7a2e07f1e8fa23b66b8315d00251b6e06bb95be13d8aa003c03da61a31a
4
- data.tar.gz: ccee2542ae61bb6de2593ac355cf19caf7875d42ecfb19adc15905857472be39
3
+ metadata.gz: 80840c17572abec1f76bc0b66fadf26f82794ece72f8b8240d32adfae917dfeb
4
+ data.tar.gz: e947035d3a2ce3490aac22e3458cd83dcefd1af652ae5416e8a2b6cfca397be6
5
5
  SHA512:
6
- metadata.gz: 2865c1e30ca1b080dba0c186dbcd1b3656b9aae9a5c793727738f34c3c6b110c53c266d0be46a28aa8f005794b8e48aca551cd37d92d63f08ac479bb6439c37b
7
- data.tar.gz: 7b793a31d222ba4ac8621812c5c963289aed3b4aee10dc31818266dd25b92db85d3f1ea25bcb29af94472010d5fbbf356f60e049c1ba797186ef818159ce9ac1
6
+ metadata.gz: 4a5b71c0a0b027fd695cc400e5278dd1fa5fc0f3af17392678c0aa68752de45a6a2f5529c5a5c0ba39d8379d3436eee915c0c6a3a14802af7d6e80358d54be00
7
+ data.tar.gz: e79b94e4c7dc6fb58bdebaa272377ff4244255ea5335cd39a634c287c7d051f44f24f1536ac5cfbf7fd879619b5b884448f4bb08eab1cc7cb188f500e92054de
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Refinement Changes
2
2
 
3
+ ## 0.7.0 (2023-08-11)
4
+
5
+ ##### Breaking Changes
6
+
7
+ Since the introduction of the class '**Refinement**'
8
+ [in Ruby 3.1](https://ruby-doc.org/core-3.1.0/Refinement.html), there is a naming
9
+ collision that requires this gem to be updated.
10
+
11
+ We have prefixed the `Refinement` module with `Sq::` to minimize the amount of
12
+ downstream changes required, in addition to maintaining the meaning and purpose
13
+ of the project.
14
+
15
+ ##### Enhancements
16
+
17
+ * Update dependencies to modern versions
18
+
3
19
  ## 0.3.1 (2019-08-09)
4
20
 
5
21
  ##### Bug Fixes
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.6.1
1
+ 0.7.0
data/exe/refine CHANGED
@@ -2,6 +2,6 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require 'refinement'
5
- require 'refinement/cli'
5
+ require 'sq/refinement/cli'
6
6
 
7
7
  Refinement::CLI.run(ARGV)
@@ -11,9 +11,9 @@ Pod::Installer
11
11
 
12
12
  raise Pod::Informative, 'Refinement requires a CocoaPods version >= 1.6.0' unless Gem::Version.create(Pod::VERSION) >= Gem::Version.create('1.6.0')
13
13
 
14
- require 'refinement/cocoapods_post_install_writer'
14
+ require 'sq/refinement/cocoapods_post_install_writer'
15
15
  Pod::UI.message 'Writing refinement file' do
16
- Refinement::CocoaPodsPostInstallWriter.new(aggregate_targets, pod_targets, config, plugins['refinement']).write!
16
+ Sq::Refinement::CocoaPodsPostInstallWriter.new(aggregate_targets, pod_targets, config, plugins['refinement']).write!
17
17
  end
18
18
  end
19
19
  end)
data/lib/refinement.rb CHANGED
@@ -1,29 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'xcodeproj'
4
-
5
- # Generates a list of Xcode targets to build & test as a result of a git diff.
6
- module Refinement
7
- class Error < StandardError; end
8
-
9
- # @visibility private
10
- # @param enum [Enumerable]
11
- # Enumerates through `enum`, and applied the given block to each element.
12
- # If the result of calling the block is truthy, the first such result is returned.
13
- # If no such result is found, `nil` is returned.
14
- def self.map_find(enum)
15
- enum.each do |elem|
16
- transformed = yield elem
17
- return transformed if transformed
18
- end
19
-
20
- nil
21
- end
22
-
23
- require 'refinement/version'
24
-
25
- require 'refinement/analyzer'
26
- require 'refinement/annotated_target'
27
- require 'refinement/changeset'
28
- require 'refinement/used_path'
29
- end
3
+ require 'sq/refinement/version'
4
+ require 'sq/refinement/setup'
5
+ require 'sq/refinement/analyzer'
6
+ require 'sq/refinement/annotated_target'
7
+ require 'sq/refinement/changeset'
8
+ require 'sq/refinement/used_path'
@@ -0,0 +1,396 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xcodeproj'
4
+
5
+ module Sq
6
+ module Refinement
7
+ # Analyzes changes in a repository
8
+ # and determines how those changes impact the targets in Xcode projects in the workspace.
9
+ class Analyzer
10
+ attr_reader :changesets, :workspace_path, :augmenting_paths_yaml_files
11
+ private :changesets, :workspace_path, :augmenting_paths_yaml_files
12
+
13
+ # Initializes an analyzer with changesets, projects, and augmenting paths.
14
+ # @param changesets [Array<Changeset>]
15
+ # @param workspace_path [Pathname] path to a root workspace or project,
16
+ # must be `nil` if `projects` are specified explicitly
17
+ # @param projects [Array<Xcodeproj::Project>] projects to find targets in,
18
+ # must not be specified if `workspace_path` is not `nil`
19
+ # @param augmenting_paths_yaml_files [Array<Pathname>] paths to YAML files that provide augmenting paths by target,
20
+ # must be `nil` if `augmenting_paths_by_target` are specified explicitly
21
+ # @param augmenting_paths_by_target [Hash<String, Array>] arrays of hashes keyed by target name
22
+ # (or '*' for all targets)
23
+ # describing paths or globs that each target should be considered to be using,
24
+ # must not be specified if `augmenting_paths_yaml_files` is not `nil`
25
+ #
26
+ # @raise [ArgumentError] when conflicting arguments are given
27
+ #
28
+ def initialize(changesets:, workspace_path:, augmenting_paths_yaml_files:, projects: nil, augmenting_paths_by_target: nil)
29
+ @changesets = changesets
30
+
31
+ raise ArgumentError, 'Can only specify one of workspace_path and projects' if workspace_path && projects
32
+
33
+ @workspace_path = workspace_path
34
+ @projects = projects
35
+
36
+ raise ArgumentError, 'Can only specify one of augmenting_paths_yaml_files and augmenting_paths_by_target' if augmenting_paths_yaml_files && augmenting_paths_by_target
37
+
38
+ @augmenting_paths_yaml_files = augmenting_paths_yaml_files
39
+ @augmenting_paths_by_target = augmenting_paths_by_target
40
+ end
41
+
42
+ # @return [Array<AnnotatedTarget>] targets from the projects annotated with their changes, based upon
43
+ # the changeset
44
+ def annotate_targets!
45
+ @annotate_targets ||= annotated_targets
46
+ end
47
+
48
+ # @param scheme_path [Pathname] the absolute path to the scheme to be filtered
49
+ # @param change_level [Symbol] the change level at which a target must have changed in order
50
+ # to remain in the scheme. defaults to `:full_transitive`
51
+ # @param filter_when_scheme_has_changed [Boolean] whether the scheme should be filtered
52
+ # even when the changeset includes the scheme's path as changed.
53
+ # Defaults to `false`
54
+ # @param log_changes [Boolean] whether modifications to the scheme are logged.
55
+ # Defaults to `false`
56
+ # @param filter_scheme_for_build_action [:building, :testing]
57
+ # The xcodebuild action the scheme is being filtered for. The currently supported values are
58
+ # `:building` and `:testing`, with the only difference being `BuildActionEntry` are not
59
+ # filtered out when building for testing, since test action macro expansion could
60
+ # depend on a build entry being present.
61
+ # @param each_target [Proc] A proc called each time a target was determined to have changed or not.
62
+ # @return [Xcodeproj::XCScheme] a scheme whose unchanged targets have been removed.
63
+ def filtered_scheme(scheme_path:, filter_scheme_for_build_action:, change_level: :full_transitive, filter_when_scheme_has_changed: false, log_changes: false, each_target: nil)
64
+ scheme = Xcodeproj::XCScheme.new(scheme_path)
65
+
66
+ sections_to_filter =
67
+ case filter_scheme_for_build_action
68
+ when :building
69
+ %w[BuildActionEntry TestableReference]
70
+ when :testing
71
+ # don't want to filter out build action entries running
72
+ # xcodebuild build-for-testing / test, since the test action could have a macro expansion
73
+ # that depends upon one of the build targets.
74
+ %w[TestableReference]
75
+ else
76
+ raise ArgumentError,
77
+ 'The supported values for the `filter_scheme_for_build_action` parameter are: [:building, :testing]. ' \
78
+ "Given: #{filter_scheme_for_build_action.inspect}."
79
+ end
80
+
81
+ if !filter_when_scheme_has_changed &&
82
+ UsedPath.new(path: Pathname(scheme_path), inclusion_reason: 'scheme').find_in_changesets(changesets)
83
+ return scheme
84
+ end
85
+
86
+ changes_by_suite_name = annotate_targets!
87
+ .to_h { |at| [at.xcode_target.name, at.change_reason(level: change_level)] }
88
+
89
+ doc = scheme.doc
90
+
91
+ xpaths = sections_to_filter.map { |section| "//*/#{section}/BuildableReference" }
92
+ xpaths.each do |xpath|
93
+ doc.get_elements(xpath).to_a.each do |buildable_reference|
94
+ suite_name = buildable_reference.attributes['BlueprintName']
95
+ if (change_reason = changes_by_suite_name[suite_name])
96
+ puts "#{suite_name} changed because #{change_reason}" if log_changes
97
+ each_target&.call(type: :changed, target_name: suite_name, change_reason:)
98
+ next
99
+ end
100
+ puts "#{suite_name} did not change, removing from scheme" if log_changes
101
+ each_target&.call(type: :unchanged, target_name: suite_name, change_reason: nil)
102
+ buildable_reference.parent.remove
103
+ end
104
+ end
105
+
106
+ if filter_scheme_for_build_action == :testing
107
+ doc.get_elements('//*/BuildActionEntry/BuildableReference').to_a.each do |buildable_reference|
108
+ suite_name = buildable_reference.attributes['BlueprintName']
109
+ if (change_reason = changes_by_suite_name[suite_name])
110
+ puts "#{suite_name} changed because #{change_reason}" if log_changes
111
+ each_target&.call(type: :changed, target_name: suite_name, change_reason:)
112
+ next
113
+ end
114
+ puts "#{suite_name} did not change, setting to not build for testing" if log_changes
115
+ each_target&.call(type: :unchanged, target_name: suite_name, change_reason: nil)
116
+ buildable_reference.parent.attributes['buildForTesting'] = 'NO'
117
+ end
118
+ end
119
+
120
+ scheme
121
+ end
122
+
123
+ # @return [String] a string suitable for user display that explains target changes
124
+ # @param include_unchanged_targets [Boolean] whether targets that have not changed should also be displayed
125
+ # @param change_level [Symbol] the change level used for computing whether a target has changed
126
+ def format_changes(include_unchanged_targets: false, change_level: :full_transitive)
127
+ annotate_targets!.group_by { |target| target.xcode_target.project.path.to_s }.sort_by(&:first)
128
+ .map do |project, annotated_targets|
129
+ changes = annotated_targets.sort_by { |annotated_target| annotated_target.xcode_target.name }
130
+ .map do |annotated_target|
131
+ change_reason = annotated_target.change_reason(level: change_level)
132
+ next if !include_unchanged_targets && !change_reason
133
+
134
+ change_reason ||= 'did not change'
135
+ "\t#{annotated_target.xcode_target}: #{change_reason}"
136
+ end.compact
137
+ "#{project}:\n#{changes.join("\n")}" unless changes.empty?
138
+ end.compact.join("\n")
139
+ end
140
+
141
+ private
142
+
143
+ # @return [Array<Xcodeproj::Project>]
144
+ def projects
145
+ @projects ||= find_projects(workspace_path)
146
+ end
147
+
148
+ # @return [Hash<String,Array<Hash>>]
149
+ def augmenting_paths_by_target
150
+ @augmenting_paths_by_target ||= begin
151
+ require 'yaml'
152
+ augmenting_paths_yaml_files.reduce({}) do |augmenting_paths_by_target, yaml_file|
153
+ yaml_file = Pathname(yaml_file).expand_path(changesets.first.repository)
154
+ yaml = YAML.safe_load(yaml_file.read)
155
+ augmenting_paths_by_target.merge(yaml) do |_target_name, prior_paths, new_paths|
156
+ prior_paths + new_paths
157
+ end
158
+ end
159
+ end
160
+ end
161
+
162
+ # @return [Array<AnnotatedTarget>] targets in the given list of Xcode projects,
163
+ # annotated according to the given changeset
164
+ def annotated_targets
165
+ workspace_modification = find_workspace_modification_in_changesets
166
+ project_changes = projects.to_h do |project|
167
+ [project, find_project_modification_in_changesets(project:) || workspace_modification]
168
+ end
169
+
170
+ require 'tsort'
171
+ targets = projects.flat_map(&:targets)
172
+ targets_by_uuid = targets.to_h { |t| [t.uuid, t] }
173
+ targets_by_name = targets.to_h { |t| [t.name, t] }
174
+ targets_by_product_name = targets.each_with_object({}) do |t, h|
175
+ next unless t.respond_to?(:product_reference)
176
+ h[File.basename(t.product_reference.path)] = t
177
+ h[File.basename(t.product_reference.name)] = t if t.product_reference.name
178
+ end
179
+
180
+ find_dep = ->(td) { targets_by_uuid[td.native_target_uuid] || targets_by_name[td.name] }
181
+ target_deps = lambda do |target|
182
+ target_dependencies = []
183
+ target.dependencies.each do |td|
184
+ target_dependencies << find_dep[td]
185
+ end
186
+
187
+ # TODO: also resolve OTHER_LDFLAGS?
188
+ # yay auto-linking
189
+ if (phase = target.frameworks_build_phases)
190
+ phase.files_references.each do |fr|
191
+ if (dt = fr&.path && targets_by_product_name[File.basename(fr.path)])
192
+ target_dependencies << dt
193
+ end
194
+ end
195
+ end
196
+
197
+ target_dependencies
198
+ end
199
+
200
+ targets = TSort.tsort(
201
+ ->(&b) { targets.each(&b) },
202
+ ->(target, &b) { target_deps[target].each(&b) }
203
+ )
204
+
205
+ targets.each_with_object({}) do |target, h|
206
+ change_reason = project_changes[target.project] || find_target_modification_in_changesets(target:)
207
+
208
+ h[target] = AnnotatedTarget.new(
209
+ target:,
210
+ dependencies: target_deps[target].map { |td| h.fetch(td) },
211
+ change_reason:
212
+ )
213
+ end.values
214
+ end
215
+
216
+ # @return [Array<Xcodeproj::Project>] the projects found by walking the
217
+ # project/workspace at the given path
218
+ # @param path [Pathname] path to a `.xcodeproj` or `.xcworkspace` on disk
219
+ def find_projects(path)
220
+ seen = {}
221
+ find_projects_cached = lambda do |project_path|
222
+ return if seen.key?(project_path)
223
+
224
+ case File.extname(project_path)
225
+ when '.xcodeproj'
226
+ project = Xcodeproj::Project.open(project_path)
227
+ seen[project_path] = project
228
+ project.files.each do |file_reference|
229
+ next unless File.extname(file_reference.path) == '.xcodeproj'
230
+
231
+ find_projects_cached[file_reference.real_path]
232
+ end
233
+ when '.xcworkspace'
234
+ workspace = Xcodeproj::Workspace.new_from_xcworkspace(project_path)
235
+ workspace.file_references.each do |file_reference|
236
+ next unless File.extname(file_reference.path) == '.xcodeproj'
237
+
238
+ find_projects_cached[file_reference.absolute_path(File.dirname(project_path))]
239
+ end
240
+ else
241
+ raise ArgumentError, "Unknown path #{project_path.inspect}"
242
+ end
243
+ end
244
+ find_projects_cached[path]
245
+
246
+ seen.values
247
+ end
248
+
249
+ # @yieldparam used_path [UsedPath] an absolute path that belongs to the given target
250
+ # @return [Void]
251
+ # @param target [Xcodeproj::Project::AbstractTarget]
252
+ def target_each_file_path(target:)
253
+ return enum_for(__method__, target:) unless block_given?
254
+
255
+ expand_build_settings = lambda do |s|
256
+ return [s] unless s =~ /\$(?:\{([_a-zA-Z0-0]+?)\}|\(([_a-zA-Z0-0]+?)\))/
257
+
258
+ match, key = Regexp.last_match.values_at(0, 1, 2).compact
259
+ substitutions = target.resolved_build_setting(key, true).values.compact.uniq
260
+ substitutions.flat_map do |sub|
261
+ expand_build_settings[s.gsub(match, sub)]
262
+ end
263
+ end
264
+
265
+ target.build_configuration_list.build_configurations.each do |build_configuration|
266
+ ref = build_configuration.base_configuration_reference
267
+ next unless ref
268
+
269
+ yield UsedPath.new(path: ref.real_path,
270
+ inclusion_reason: "base configuration reference for #{build_configuration}")
271
+ end
272
+
273
+ target.build_phases.each do |build_phase|
274
+ build_phase.files_references.each do |fr|
275
+ next unless fr
276
+
277
+ yield UsedPath.new(path: fr.real_path,
278
+ inclusion_reason: "#{build_phase.display_name.downcase.chomp('s')} file")
279
+ end
280
+ end
281
+
282
+ target.shell_script_build_phases.each do |shell_script_build_phase|
283
+ %w[input_file_list_paths output_file_list_paths input_paths output_paths].each do |method|
284
+ next unless (paths = shell_script_build_phase.public_send(method))
285
+
286
+ file_type = method.tr('_', ' ').chomp('s')
287
+ paths.each do |config_path|
288
+ next unless config_path
289
+
290
+ expand_build_settings[config_path].each do |path|
291
+ path = Pathname(path).expand_path(target.project.project_dir)
292
+ yield UsedPath.new(path:,
293
+ inclusion_reason: "#{shell_script_build_phase.name} build phase #{file_type}")
294
+ end
295
+ end
296
+ end
297
+ end
298
+
299
+ %w[INFOPLIST_FILE HEADER_SEARCH_PATHS FRAMEWORK_SEARCH_PATHS USER_HEADER_SEARCH_PATHS].each do |build_setting|
300
+ target.resolved_build_setting(build_setting, true).each_value do |paths|
301
+ Array(paths).each do |path|
302
+ next unless path
303
+
304
+ path = Pathname(path).expand_path(target.project.project_dir)
305
+ yield UsedPath.new(path:, inclusion_reason: "#{build_setting} value")
306
+ end
307
+ end
308
+ end
309
+ end
310
+
311
+ # @return [FileModification,Nil] a modification to a file that is used by the given target, or `nil`
312
+ # if none if found
313
+ # @param target [Xcodeproj::Project::AbstractTarget]
314
+ def find_target_modification_in_changesets(target:)
315
+ augmenting_paths = used_paths_from_augmenting_paths_by_target[target.name]
316
+ find_in_changesets = ->(path) { path.find_in_changesets(changesets) }
317
+ Sq::Refinement.map_find(augmenting_paths, &find_in_changesets) ||
318
+ Sq::Refinement.map_find(target_each_file_path(target:), &find_in_changesets)
319
+ end
320
+
321
+ # @yieldparam used_path [UsedPath] an absolute path that belongs to the given project
322
+ # @return [Void]
323
+ # @param project [Xcodeproj::Project]
324
+ def project_each_file_path(project:)
325
+ return enum_for(__method__, project:) unless block_given?
326
+
327
+ yield UsedPath.new(path: project.path, inclusion_reason: 'project directory')
328
+
329
+ project.root_object.build_configuration_list.build_configurations.each do |build_configuration|
330
+ ref = build_configuration.base_configuration_reference
331
+ next unless ref
332
+
333
+ yield UsedPath.new(path: ref.real_path,
334
+ inclusion_reason: "base configuration reference for #{build_configuration}")
335
+ end
336
+ end
337
+
338
+ # # @return [FileModification,Nil] a modification to a file that is directly used by the given project, or `nil`
339
+ # if none if found
340
+ # @note This method does not take into account whatever file paths targets in the project may reference
341
+ # @param project [Xcodeproj::Project]
342
+ def find_project_modification_in_changesets(project:)
343
+ Sq::Refinement.map_find(project_each_file_path(project:)) do |path|
344
+ path.find_in_changesets(changesets)
345
+ end
346
+ end
347
+
348
+ # @return [FileModification,Nil] a modification to the workspace itself, or `nil`
349
+ # if none if found
350
+ # @note This method does not take into account whatever file paths projects or
351
+ # targets in the workspace path may reference
352
+ def find_workspace_modification_in_changesets
353
+ return unless workspace_path
354
+
355
+ UsedPath.new(path: workspace_path, inclusion_reason: 'workspace directory')
356
+ .find_in_changesets(changesets)
357
+ end
358
+
359
+ # @return [Hash<String,UsedPath>]
360
+ def used_paths_from_augmenting_paths_by_target
361
+ @used_paths_from_augmenting_paths_by_target ||= begin
362
+ repo = changesets.first.repository
363
+ used_paths_from_augmenting_paths_by_target =
364
+ augmenting_paths_by_target.transform_values do |augmenting_paths|
365
+ augmenting_paths.map do |augmenting_path|
366
+ case augmenting_path.keys.sort
367
+ when %w[inclusion_reason path], %w[inclusion_reason path yaml_keypath]
368
+ kwargs = {
369
+ path: Pathname(augmenting_path['path']).expand_path(repo),
370
+ inclusion_reason: augmenting_path['inclusion_reason']
371
+ }
372
+ if augmenting_path.key?('yaml_keypath')
373
+ kwargs[:yaml_keypath] = augmenting_path['yaml_keypath']
374
+ UsedPath::YAML.new(**kwargs)
375
+ else
376
+ UsedPath.new(**kwargs)
377
+ end
378
+ when %w[glob inclusion_reason]
379
+ UsedGlob.new(glob: File.expand_path(augmenting_path['glob'], repo),
380
+ inclusion_reason: augmenting_path['inclusion_reason'])
381
+ else
382
+ raise ArgumentError,
383
+ "unhandled set of keys in augmenting paths dictionary entry: #{augmenting_path.keys.inspect}"
384
+ end
385
+ end
386
+ end
387
+ wildcard_paths = used_paths_from_augmenting_paths_by_target.fetch('*', [])
388
+
389
+ Hash.new do |h, k|
390
+ h[k] = wildcard_paths + used_paths_from_augmenting_paths_by_target.fetch(k, [])
391
+ end
392
+ end
393
+ end
394
+ end
395
+ end
396
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'xcodeproj'
4
+
5
+ module Sq
6
+ module Refinement
7
+ # A target, annotated with any changes
8
+ class AnnotatedTarget
9
+ # @return [Xcodeproj::Project::AbstactTarget] the target in an Xcode project
10
+ attr_reader :xcode_target
11
+
12
+ # @return [String,Nil] the reason why the target has changed, or `nil` if it has not changed
13
+ attr_reader :direct_change_reason
14
+ private :direct_change_reason
15
+
16
+ def initialize(target:, change_reason:, dependencies: [])
17
+ @xcode_target = target
18
+ @direct_change_reason = change_reason
19
+ @dependencies = dependencies
20
+ dependencies.each do |dependency|
21
+ dependency.depended_upon_by << self
22
+ end
23
+ @depended_upon_by = []
24
+ end
25
+
26
+ # @visibility private
27
+ def to_s
28
+ xcode_target.to_s
29
+ end
30
+
31
+ CHANGE_LEVELS = %i[
32
+ itself
33
+ at_most_n_away
34
+ full_transitive
35
+ ].freeze
36
+ private_constant :CHANGE_LEVELS
37
+
38
+ # @return [Boolean] whether the target has changed, at the given change level
39
+ # @param level [Symbol,(:at_most_n_away,Integer)] change level, e.g. :itself, :at_most_n_away, :full_transitive
40
+ def change_reason(level:)
41
+ @change_reason ||= {}
42
+ # need to use this form for memoization, as opposed to ||=,
43
+ # since this will (often) be nil and it makes a significant performance difference
44
+ return @change_reason[level] if @change_reason.key?(level)
45
+
46
+ @change_reason[level] =
47
+ case level
48
+ when :itself
49
+ direct_change_reason
50
+ when :full_transitive
51
+ direct_change_reason || Sq::Refinement.map_find(dependencies) do |dependency|
52
+ next unless (dependency_change_reason = dependency.change_reason(level:))
53
+
54
+ "dependency #{dependency} changed because #{dependency_change_reason}"
55
+ end
56
+ when proc { |symbol, int| (symbol == :at_most_n_away) && int.is_a?(Integer) }
57
+ distance_from_target = level.last
58
+ raise ArgumentError, "level must be positive, not #{distance_from_target}" if distance_from_target.negative?
59
+
60
+ change_reason = direct_change_reason
61
+ if distance_from_target.positive?
62
+ change_reason ||= Sq::Refinement.map_find(dependencies) do |dependency|
63
+ unless (dependency_change_reason = dependency.change_reason(level: [:at_most_n_away, level.last.pred]))
64
+ next
65
+ end
66
+
67
+ "dependency #{dependency} changed because #{dependency_change_reason}"
68
+ end
69
+ end
70
+ change_reason
71
+ else
72
+ raise Error, "No known change level #{level.inspect}, only #{CHANGE_LEVELS.inspect} are known"
73
+ end
74
+ end
75
+
76
+ # @return [Array<AnnotatedTarget>] the list of annotated targets this target depends upon
77
+ attr_reader :dependencies
78
+ # @return [Array<AnnotatedTarget>] the list of annotated targets that depend upon this target
79
+ attr_reader :depended_upon_by
80
+ end
81
+ end
82
+ end