dependabot-nuget 0.237.0 → 0.239.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/lib/dependabot/nuget/cache_manager.rb +22 -0
- data/lib/dependabot/nuget/file_fetcher/import_paths_finder.rb +15 -0
- data/lib/dependabot/nuget/file_fetcher.rb +61 -64
- data/lib/dependabot/nuget/file_parser/dotnet_tools_json_parser.rb +2 -1
- data/lib/dependabot/nuget/file_parser/global_json_parser.rb +2 -1
- data/lib/dependabot/nuget/file_parser/packages_config_parser.rb +22 -4
- data/lib/dependabot/nuget/file_parser/project_file_parser.rb +287 -15
- data/lib/dependabot/nuget/file_parser/property_value_finder.rb +24 -52
- data/lib/dependabot/nuget/file_parser.rb +4 -1
- data/lib/dependabot/nuget/file_updater.rb +123 -117
- data/lib/dependabot/nuget/native_helpers.rb +94 -0
- data/lib/dependabot/nuget/requirement.rb +5 -1
- data/lib/dependabot/nuget/update_checker/compatibility_checker.rb +85 -0
- data/lib/dependabot/nuget/update_checker/dependency_finder.rb +228 -0
- data/lib/dependabot/nuget/update_checker/nupkg_fetcher.rb +119 -0
- data/lib/dependabot/nuget/update_checker/nuspec_fetcher.rb +83 -0
- data/lib/dependabot/nuget/update_checker/property_updater.rb +30 -3
- data/lib/dependabot/nuget/update_checker/repository_finder.rb +36 -10
- data/lib/dependabot/nuget/update_checker/tfm_comparer.rb +31 -0
- data/lib/dependabot/nuget/update_checker/tfm_finder.rb +127 -0
- data/lib/dependabot/nuget/update_checker/version_finder.rb +47 -6
- data/lib/dependabot/nuget/update_checker.rb +42 -8
- data/lib/dependabot/nuget.rb +2 -0
- metadata +35 -9
- data/lib/dependabot/nuget/file_updater/packages_config_declaration_finder.rb +0 -70
- data/lib/dependabot/nuget/file_updater/project_file_declaration_finder.rb +0 -183
@@ -1,19 +1,22 @@
|
|
1
1
|
# typed: true
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
|
+
require "dependabot/dependency_file"
|
4
5
|
require "dependabot/file_updaters"
|
5
6
|
require "dependabot/file_updaters/base"
|
7
|
+
require "dependabot/nuget/native_helpers"
|
6
8
|
|
7
9
|
module Dependabot
|
8
10
|
module Nuget
|
9
11
|
class FileUpdater < Dependabot::FileUpdaters::Base
|
10
|
-
require_relative "file_updater/packages_config_declaration_finder"
|
11
|
-
require_relative "file_updater/project_file_declaration_finder"
|
12
12
|
require_relative "file_updater/property_value_updater"
|
13
|
+
require_relative "file_parser/project_file_parser"
|
14
|
+
require_relative "file_parser/dotnet_tools_json_parser"
|
15
|
+
require_relative "file_parser/packages_config_parser"
|
13
16
|
|
14
17
|
def self.updated_files_regex
|
15
18
|
[
|
16
|
-
%r{^[^/]*\.[a-z]{2}proj$},
|
19
|
+
%r{^[^/]*\.([a-z]{2})?proj$},
|
17
20
|
/^packages\.config$/i,
|
18
21
|
/^global\.json$/i,
|
19
22
|
/^dotnet-tools\.json$/i,
|
@@ -24,156 +27,159 @@ module Dependabot
|
|
24
27
|
end
|
25
28
|
|
26
29
|
def updated_dependency_files
|
27
|
-
updated_files = T.let(dependency_files.dup, T.untyped)
|
28
|
-
|
29
|
-
# Loop through each of the changed requirements, applying changes to
|
30
|
-
# all files for that change. Note that the logic is different here
|
31
|
-
# to other languages because donet has property inheritance across
|
32
|
-
# files
|
33
30
|
dependencies.each do |dependency|
|
34
|
-
|
35
|
-
files: updated_files,
|
36
|
-
dependency: dependency
|
37
|
-
)
|
31
|
+
try_update_projects(dependency) || try_update_json(dependency)
|
38
32
|
end
|
39
33
|
|
40
|
-
|
34
|
+
# update all with content from disk
|
35
|
+
updated_files = dependency_files.filter_map do |f|
|
36
|
+
updated_content = File.read(dependency_file_path(f))
|
37
|
+
next if updated_content == f.content
|
41
38
|
|
42
|
-
|
39
|
+
normalized_content = normalize_content(f, updated_content)
|
40
|
+
next if normalized_content == f.content
|
41
|
+
|
42
|
+
puts "The contents of file [#{f.name}] were updated."
|
43
|
+
|
44
|
+
updated_file(file: f, content: normalized_content)
|
45
|
+
end
|
46
|
+
|
47
|
+
# reset repo files
|
48
|
+
SharedHelpers.reset_git_repo(T.cast(repo_contents_path, String)) if repo_contents_path
|
43
49
|
|
44
50
|
updated_files
|
45
51
|
end
|
46
52
|
|
47
53
|
private
|
48
54
|
|
49
|
-
def
|
50
|
-
|
55
|
+
def try_update_projects(dependency)
|
56
|
+
update_ran = T.let(false, T::Boolean)
|
57
|
+
|
58
|
+
# run update for each project file
|
59
|
+
project_files.each do |project_file|
|
60
|
+
project_dependencies = project_dependencies(project_file)
|
61
|
+
proj_path = dependency_file_path(project_file)
|
62
|
+
|
63
|
+
next unless project_dependencies.any? { |dep| dep.name.casecmp(dependency.name).zero? }
|
64
|
+
|
65
|
+
NativeHelpers.run_nuget_updater_tool(repo_contents_path, proj_path, dependency, !dependency.top_level?)
|
66
|
+
update_ran = true
|
67
|
+
end
|
68
|
+
|
69
|
+
update_ran
|
51
70
|
end
|
52
71
|
|
53
|
-
def
|
54
|
-
|
55
|
-
|
72
|
+
def try_update_json(dependency)
|
73
|
+
if dotnet_tools_json_dependencies.any? { |dep| dep.name.casecmp(dependency.name).zero? } ||
|
74
|
+
global_json_dependencies.any? { |dep| dep.name.casecmp(dependency.name).zero? }
|
75
|
+
|
76
|
+
# We just need to feed the updater a project file, grab the first
|
77
|
+
project_file = project_files.first
|
78
|
+
proj_path = dependency_file_path(project_file)
|
79
|
+
|
80
|
+
NativeHelpers.run_nuget_updater_tool(repo_contents_path, proj_path, dependency, !dependency.top_level?)
|
81
|
+
return true
|
56
82
|
end
|
83
|
+
|
84
|
+
false
|
57
85
|
end
|
58
86
|
|
59
|
-
def
|
60
|
-
|
87
|
+
def project_dependencies(project_file)
|
88
|
+
# Collect all dependencies from the project file and associated packages.config
|
89
|
+
dependencies = project_file_parser.dependency_set(project_file: project_file).dependencies
|
90
|
+
packages_config = find_packages_config(project_file)
|
91
|
+
return dependencies unless packages_config
|
92
|
+
|
93
|
+
dependencies + FileParser::PackagesConfigParser.new(packages_config: packages_config)
|
94
|
+
.dependency_set.dependencies
|
61
95
|
end
|
62
96
|
|
63
|
-
def
|
64
|
-
|
97
|
+
def find_packages_config(project_file)
|
98
|
+
project_file_name = File.basename(project_file.name)
|
99
|
+
packages_config_path = project_file.name.gsub(project_file_name, "packages.config")
|
100
|
+
packages_config_files.find { |f| f.name == packages_config_path }
|
65
101
|
end
|
66
102
|
|
67
|
-
def
|
68
|
-
|
103
|
+
def project_file_parser
|
104
|
+
@project_file_parser ||=
|
105
|
+
FileParser::ProjectFileParser.new(
|
106
|
+
dependency_files: dependency_files,
|
107
|
+
credentials: credentials
|
108
|
+
)
|
109
|
+
end
|
69
110
|
|
70
|
-
|
111
|
+
def global_json_dependencies
|
112
|
+
return [] unless global_json
|
113
|
+
|
114
|
+
@global_json_dependencies ||=
|
115
|
+
FileParser::GlobalJsonParser.new(global_json: global_json).dependency_set.dependencies
|
71
116
|
end
|
72
117
|
|
73
|
-
def
|
74
|
-
|
75
|
-
# when updating, so we can zip them together in new/old pairs.
|
76
|
-
reqs = dependency.requirements.zip(dependency.previous_requirements)
|
77
|
-
.reject { |new_req, old_req| new_req == old_req }
|
118
|
+
def dotnet_tools_json_dependencies
|
119
|
+
return [] unless dotnet_tools_json
|
78
120
|
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
next if new_req[:requirement] == old_req[:requirement]
|
121
|
+
@dotnet_tools_json_dependencies ||=
|
122
|
+
FileParser::DotNetToolsJsonParser.new(dotnet_tools_json: dotnet_tools_json).dependency_set.dependencies
|
123
|
+
end
|
83
124
|
|
84
|
-
|
125
|
+
def normalize_content(dependency_file, updated_content)
|
126
|
+
# Fix up line endings
|
127
|
+
if dependency_file.content.include?("\r\n") && updated_content.match?(/(?<!\r)\n/)
|
128
|
+
# The original content contain windows style newlines.
|
129
|
+
# Ensure the updated content also uses windows style newlines.
|
130
|
+
updated_content = updated_content.gsub(/(?<!\r)\n/, "\r\n")
|
131
|
+
puts "Fixing mismatched Windows line endings for [#{dependency_file.name}]."
|
132
|
+
elsif updated_content.include?("\r\n")
|
133
|
+
# The original content does not contain windows style newlines.
|
134
|
+
# Ensure the updated content uses unix style newlines.
|
135
|
+
updated_content = updated_content.gsub("\r\n", "\n")
|
136
|
+
puts "Fixing mismatched Unix line endings for [#{dependency_file.name}]."
|
137
|
+
end
|
85
138
|
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
139
|
+
# Fix up BOM
|
140
|
+
if !dependency_file.content.start_with?("\uFEFF") && updated_content.start_with?("\uFEFF")
|
141
|
+
updated_content = updated_content.delete_prefix("\uFEFF")
|
142
|
+
puts "Removing BOM from [#{dependency_file.name}]."
|
143
|
+
elsif dependency_file.content.start_with?("\uFEFF") && !updated_content.start_with?("\uFEFF")
|
144
|
+
updated_content = "\uFEFF" + updated_content
|
145
|
+
puts "Adding BOM to [#{dependency_file.name}]."
|
92
146
|
end
|
93
147
|
|
94
|
-
|
148
|
+
updated_content
|
95
149
|
end
|
96
150
|
|
97
|
-
def
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
.
|
103
|
-
.
|
104
|
-
|
105
|
-
updated_value: req.fetch(:requirement),
|
106
|
-
callsite_file: file
|
107
|
-
)
|
151
|
+
def dependency_file_path(dependency_file)
|
152
|
+
if dependency_file.directory.start_with?(repo_contents_path)
|
153
|
+
File.join(dependency_file.directory, dependency_file.name)
|
154
|
+
else
|
155
|
+
file_directory = dependency_file.directory
|
156
|
+
file_directory = file_directory[1..-1] if file_directory.start_with?("/")
|
157
|
+
File.join(repo_contents_path || "", file_directory, dependency_file.name)
|
158
|
+
end
|
108
159
|
end
|
109
160
|
|
110
|
-
def
|
111
|
-
|
112
|
-
|
113
|
-
updated_content = file.content
|
161
|
+
def project_files
|
162
|
+
dependency_files.select { |df| df.name.match?(/\.([a-z]{2})?proj$/) }
|
163
|
+
end
|
114
164
|
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
updated_declaration(old_dec, old_req, new_req)
|
119
|
-
)
|
165
|
+
def packages_config_files
|
166
|
+
dependency_files.select do |f|
|
167
|
+
T.must(T.must(f.name.split("/").last).casecmp("packages.config")).zero?
|
120
168
|
end
|
169
|
+
end
|
121
170
|
|
122
|
-
|
123
|
-
|
124
|
-
files[files.index(file)] =
|
125
|
-
updated_file(file: file, content: updated_content)
|
126
|
-
files
|
127
|
-
end
|
128
|
-
|
129
|
-
def original_declarations(dependency, requirement)
|
130
|
-
if requirement.fetch(:file).casecmp("global.json").zero?
|
131
|
-
[
|
132
|
-
global_json.content.match(
|
133
|
-
/"#{Regexp.escape(dependency.name)}"\s*:\s*
|
134
|
-
"#{Regexp.escape(dependency.previous_version)}"/x
|
135
|
-
).to_s
|
136
|
-
]
|
137
|
-
elsif requirement.fetch(:file).casecmp(".config/dotnet-tools.json").zero?
|
138
|
-
[
|
139
|
-
dotnet_tools_json.content.match(
|
140
|
-
/"#{Regexp.escape(dependency.name)}"\s*:\s*{\s*"version"\s*:\s*
|
141
|
-
"#{Regexp.escape(dependency.previous_version)}"/xm
|
142
|
-
).to_s
|
143
|
-
]
|
144
|
-
else
|
145
|
-
declaration_finder(dependency, requirement).declaration_strings
|
146
|
-
end
|
171
|
+
def global_json
|
172
|
+
dependency_files.find { |f| T.must(f.name.casecmp("global.json")).zero? }
|
147
173
|
end
|
148
174
|
|
149
|
-
def
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
|
156
|
-
|
157
|
-
declaring_requirement: requirement,
|
158
|
-
packages_config:
|
159
|
-
packages_config_files.find { |f| f.name == requirement_fn }
|
160
|
-
)
|
161
|
-
else
|
162
|
-
ProjectFileDeclarationFinder.new(
|
163
|
-
dependency_name: dependency.name,
|
164
|
-
declaring_requirement: requirement,
|
165
|
-
dependency_files: dependency_files
|
166
|
-
)
|
167
|
-
end
|
168
|
-
end
|
169
|
-
|
170
|
-
def updated_declaration(old_declaration, previous_req, requirement)
|
171
|
-
original_req_string = previous_req.fetch(:requirement)
|
172
|
-
|
173
|
-
old_declaration.gsub(
|
174
|
-
original_req_string,
|
175
|
-
requirement.fetch(:requirement)
|
176
|
-
)
|
175
|
+
def dotnet_tools_json
|
176
|
+
dependency_files.find { |f| T.must(f.name.casecmp(".config/dotnet-tools.json")).zero? }
|
177
|
+
end
|
178
|
+
|
179
|
+
def check_required_files
|
180
|
+
return if project_files.any? || packages_config_files.any?
|
181
|
+
|
182
|
+
raise "No project file or packages.config!"
|
177
183
|
end
|
178
184
|
end
|
179
185
|
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Dependabot
|
5
|
+
module Nuget
|
6
|
+
module NativeHelpers
|
7
|
+
def self.native_helpers_root
|
8
|
+
helpers_root = ENV.fetch("DEPENDABOT_NATIVE_HELPERS_PATH", nil)
|
9
|
+
return File.join(helpers_root, "nuget") unless helpers_root.nil?
|
10
|
+
|
11
|
+
File.expand_path("../../../helpers", __dir__)
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.run_nuget_framework_check(project_tfms, package_tfms)
|
15
|
+
exe_path = File.join(native_helpers_root, "NuGetUpdater", "NuGetUpdater.Cli")
|
16
|
+
command = [
|
17
|
+
exe_path,
|
18
|
+
"framework-check",
|
19
|
+
"--project-tfms",
|
20
|
+
*project_tfms,
|
21
|
+
"--package-tfms",
|
22
|
+
*package_tfms,
|
23
|
+
"--verbose"
|
24
|
+
].join(" ")
|
25
|
+
|
26
|
+
fingerprint = [
|
27
|
+
exe_path,
|
28
|
+
"framework-check",
|
29
|
+
"--project-tfms",
|
30
|
+
"<project-tfms>",
|
31
|
+
"--package-tfms",
|
32
|
+
"<package-tfms>",
|
33
|
+
"--verbose"
|
34
|
+
].join(" ")
|
35
|
+
|
36
|
+
puts "running NuGet updater:\n" + command
|
37
|
+
|
38
|
+
output = SharedHelpers.run_shell_command(command, fingerprint: fingerprint)
|
39
|
+
puts output
|
40
|
+
|
41
|
+
# Exit code == 0 means that all project frameworks are compatible
|
42
|
+
true
|
43
|
+
rescue Dependabot::SharedHelpers::HelperSubprocessFailed
|
44
|
+
# Exit code != 0 means that not all project frameworks are compatible
|
45
|
+
false
|
46
|
+
end
|
47
|
+
|
48
|
+
# rubocop:disable Metrics/MethodLength
|
49
|
+
def self.run_nuget_updater_tool(repo_root, proj_path, dependency, is_transitive)
|
50
|
+
exe_path = File.join(native_helpers_root, "NuGetUpdater", "NuGetUpdater.Cli")
|
51
|
+
command = [
|
52
|
+
exe_path,
|
53
|
+
"update",
|
54
|
+
"--repo-root",
|
55
|
+
repo_root,
|
56
|
+
"--solution-or-project",
|
57
|
+
proj_path,
|
58
|
+
"--dependency",
|
59
|
+
dependency.name,
|
60
|
+
"--new-version",
|
61
|
+
dependency.version,
|
62
|
+
"--previous-version",
|
63
|
+
dependency.previous_version,
|
64
|
+
is_transitive ? "--transitive" : "",
|
65
|
+
"--verbose"
|
66
|
+
].join(" ")
|
67
|
+
|
68
|
+
fingerprint = [
|
69
|
+
exe_path,
|
70
|
+
"update",
|
71
|
+
"--repo-root",
|
72
|
+
"<repo-root>",
|
73
|
+
"--solution-or-project",
|
74
|
+
"<path-to-solution-or-project>",
|
75
|
+
"--dependency",
|
76
|
+
"<dependency-name>",
|
77
|
+
"--new-version",
|
78
|
+
"<new-version>",
|
79
|
+
"--previous-version",
|
80
|
+
"<previous-version>",
|
81
|
+
is_transitive ? "--transitive" : "",
|
82
|
+
"--verbose"
|
83
|
+
].join(" ")
|
84
|
+
|
85
|
+
puts "running NuGet updater:\n" + command
|
86
|
+
|
87
|
+
output = SharedHelpers.run_shell_command(command, fingerprint: fingerprint)
|
88
|
+
|
89
|
+
puts output
|
90
|
+
end
|
91
|
+
# rubocop:enable Metrics/MethodLength
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
@@ -1,6 +1,9 @@
|
|
1
1
|
# typed: true
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
|
+
require "sorbet-runtime"
|
5
|
+
|
6
|
+
require "dependabot/requirement"
|
4
7
|
require "dependabot/utils"
|
5
8
|
require "dependabot/nuget/version"
|
6
9
|
|
@@ -8,7 +11,7 @@ require "dependabot/nuget/version"
|
|
8
11
|
# https://docs.microsoft.com/en-us/nuget/reference/package-versioning
|
9
12
|
module Dependabot
|
10
13
|
module Nuget
|
11
|
-
class Requirement <
|
14
|
+
class Requirement < Dependabot::Requirement
|
12
15
|
def self.parse(obj)
|
13
16
|
return ["=", Nuget::Version.new(obj.to_s)] if obj.is_a?(Gem::Version)
|
14
17
|
|
@@ -25,6 +28,7 @@ module Dependabot
|
|
25
28
|
# For consistency with other languages, we define a requirements array.
|
26
29
|
# Dotnet doesn't have an `OR` separator for requirements, so it always
|
27
30
|
# contains a single element.
|
31
|
+
sig { override.params(requirement_string: T.nilable(String)).returns(T::Array[Requirement]) }
|
28
32
|
def self.requirements_array(requirement_string)
|
29
33
|
[new(requirement_string)]
|
30
34
|
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "dependabot/nuget/update_checker"
|
5
|
+
|
6
|
+
module Dependabot
|
7
|
+
module Nuget
|
8
|
+
class UpdateChecker
|
9
|
+
class CompatibilityChecker
|
10
|
+
require_relative "nuspec_fetcher"
|
11
|
+
require_relative "nupkg_fetcher"
|
12
|
+
require_relative "tfm_finder"
|
13
|
+
require_relative "tfm_comparer"
|
14
|
+
|
15
|
+
def initialize(dependency_urls:, dependency:, tfm_finder:)
|
16
|
+
@dependency_urls = dependency_urls
|
17
|
+
@dependency = dependency
|
18
|
+
@tfm_finder = tfm_finder
|
19
|
+
end
|
20
|
+
|
21
|
+
def compatible?(version)
|
22
|
+
nuspec_xml = NuspecFetcher.fetch_nuspec(dependency_urls, dependency.name, version)
|
23
|
+
return false unless nuspec_xml
|
24
|
+
|
25
|
+
# development dependencies are packages such as analyzers which need to be
|
26
|
+
# compatible with the compiler not the project itself.
|
27
|
+
return true if development_dependency?(nuspec_xml)
|
28
|
+
|
29
|
+
package_tfms = parse_package_tfms(nuspec_xml)
|
30
|
+
package_tfms = fetch_package_tfms(version) if package_tfms.empty?
|
31
|
+
# nil is a special return value that indicates that the package is likely a development dependency
|
32
|
+
return true if package_tfms.nil?
|
33
|
+
return false if package_tfms.empty?
|
34
|
+
|
35
|
+
return false if project_tfms.nil? || project_tfms.empty?
|
36
|
+
|
37
|
+
TfmComparer.are_frameworks_compatible?(project_tfms, package_tfms)
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
attr_reader :dependency_urls, :dependency, :tfm_finder
|
43
|
+
|
44
|
+
def development_dependency?(nuspec_xml)
|
45
|
+
contents = nuspec_xml.at_xpath("package/metadata/developmentDependency")&.content&.strip
|
46
|
+
return false unless contents
|
47
|
+
|
48
|
+
contents.casecmp("true").zero?
|
49
|
+
end
|
50
|
+
|
51
|
+
def parse_package_tfms(nuspec_xml)
|
52
|
+
nuspec_xml.xpath("//dependencies/group").map do |group|
|
53
|
+
group.attribute("targetFramework")
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
def project_tfms
|
58
|
+
return @project_tfms if defined?(@project_tfms)
|
59
|
+
|
60
|
+
@project_tfms = tfm_finder.frameworks(dependency)
|
61
|
+
end
|
62
|
+
|
63
|
+
def fetch_package_tfms(dependency_version)
|
64
|
+
nupkg_buffer = NupkgFetcher.fetch_nupkg_buffer(dependency_urls, dependency.name, dependency_version)
|
65
|
+
return [] unless nupkg_buffer
|
66
|
+
|
67
|
+
# Parse tfms from the folders beneath the lib folder
|
68
|
+
folder_name = "lib/"
|
69
|
+
tfms = Set.new
|
70
|
+
Zip::File.open_buffer(nupkg_buffer) do |zip|
|
71
|
+
lib_file_entries = zip.select { |entry| entry.name.start_with?(folder_name) }
|
72
|
+
# If there is no lib folder in this package, assume it is a development dependency
|
73
|
+
return nil if lib_file_entries.empty?
|
74
|
+
|
75
|
+
lib_file_entries.each do |entry|
|
76
|
+
_, tfm = entry.name.split("/").first(2)
|
77
|
+
tfms << tfm
|
78
|
+
end
|
79
|
+
end
|
80
|
+
tfms.to_a
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|