emerge 0.2.2 → 0.4.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +28 -3
- data/lib/commands/global_options.rb +2 -0
- data/lib/commands/order_files/download_order_files.rb +77 -0
- data/lib/commands/order_files/validate_linkmaps.rb +55 -0
- data/lib/commands/reaper/reaper.rb +201 -0
- data/lib/commands/snapshots/validate_app.rb +64 -0
- data/lib/emerge_cli.rb +18 -2
- data/lib/reaper/ast_parser.rb +188 -3
- data/lib/reaper/code_deleter.rb +263 -0
- data/lib/utils/git.rb +13 -1
- data/lib/utils/macho_parser.rb +325 -0
- data/lib/utils/network.rb +20 -13
- data/lib/utils/version_check.rb +32 -0
- data/lib/version.rb +1 -1
- data/parsers/libtree-sitter-java-darwin-arm64.dylib +0 -0
- data/parsers/libtree-sitter-java-linux-x86_64.so +0 -0
- data/parsers/libtree-sitter-kotlin-darwin-arm64.dylib +0 -0
- data/parsers/libtree-sitter-kotlin-linux-x86_64.so +0 -0
- data/parsers/libtree-sitter-swift-darwin-arm64.dylib +0 -0
- data/parsers/libtree-sitter-swift-linux-x86_64.so +0 -0
- metadata +31 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e4ada02f9680b03cfcf2d4350d0aa719ae8c1a0399e7783b468b341e2eac5143
|
4
|
+
data.tar.gz: 7dfc873c8ba7cb9dd145b7ae181cdcadbe494741ac6f241fcdd0cb48f3c88526
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4ae1796a1d262846e12bf5b1f64301166209b0ad31f4981effb118a4c091d0f36c5396e169c881b8baea1f5fc93e18f8aa452eb1e05775eeab79b615e6b38370
|
7
|
+
data.tar.gz: ff14aac430f27b95be0d6267839d967e9869b31090c669d68c0e8fefb34dd6e3468bc627080f36ce27aaa1288d73e2698c5800614035f5218d7d71a2a5182aef
|
data/README.md
CHANGED
@@ -108,8 +108,33 @@ emerge upload snapshots \
|
|
108
108
|
--project-root /my/awesomeapp/android/repo
|
109
109
|
```
|
110
110
|
|
111
|
-
##
|
111
|
+
## Reaper
|
112
112
|
|
113
|
-
|
113
|
+
Experimental support has been added to interactively examine [Reaper](https://docs.emergetools.com/docs/reaper) results and also **delete them from your codebase**.
|
114
114
|
|
115
|
-
|
115
|
+
Use the `reaper` subcommand to get started, e.g.:
|
116
|
+
|
117
|
+
```shell
|
118
|
+
emerge reaper --upload-id 40f1dfe7-6c57-47c3-bc52-b621aec0ba8d \
|
119
|
+
--project-root /path/to/your/repo
|
120
|
+
```
|
121
|
+
|
122
|
+
After which it will prompt you to select classes to delete.
|
123
|
+
|
124
|
+
### How it works
|
125
|
+
|
126
|
+
Under the hood we are using [Tree Sitter](https://tree-sitter.github.io/tree-sitter/) to parse your source files into an AST which is then used for deletions. There are some obvious limitations to this approach, namely that Tree Sitter is designed for source code editors and only looks at a single file at a time. We are exploring some better long-term approaches but this works well enough for now!
|
127
|
+
|
128
|
+
### Supported languages
|
129
|
+
|
130
|
+
We currently support the following languages:
|
131
|
+
|
132
|
+
- Swift
|
133
|
+
- Kotlin
|
134
|
+
- Java
|
135
|
+
|
136
|
+
Please open an issue if you need an additional language grammar.
|
137
|
+
|
138
|
+
### Building
|
139
|
+
|
140
|
+
Because many of the language grammars we use are third-party, we have to package them with our CLI tool as shared libraries for distribution. We depend on [tsdl](https://github.com/stackmystack/tsdl) to build the grammars from our `parsers.toml` file.
|
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'dry/cli'
|
2
|
+
|
3
|
+
module EmergeCLI
|
4
|
+
module Commands
|
5
|
+
class DownloadOrderFiles < EmergeCLI::Commands::GlobalOptions
|
6
|
+
desc 'Download order files from Emerge'
|
7
|
+
|
8
|
+
option :bundle_id, type: :string, required: true, desc: 'Bundle identifier to download order files for'
|
9
|
+
|
10
|
+
option :api_token, type: :string, required: false,
|
11
|
+
desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]'
|
12
|
+
|
13
|
+
option :app_version, type: :string, required: true,
|
14
|
+
desc: 'App version to download order files for'
|
15
|
+
|
16
|
+
option :unzip, type: :boolean, required: false,
|
17
|
+
desc: 'Unzip the order file after downloading'
|
18
|
+
|
19
|
+
option :output, type: :string, required: false,
|
20
|
+
desc: 'Output name for the order file, defaults to bundle_id-app_version.gz'
|
21
|
+
|
22
|
+
EMERGE_ORDER_FILE_URL = 'order-files-prod.emergetools.com'.freeze
|
23
|
+
|
24
|
+
def initialize(network: nil)
|
25
|
+
@network = network
|
26
|
+
end
|
27
|
+
|
28
|
+
def call(**options)
|
29
|
+
@options = options
|
30
|
+
before(options)
|
31
|
+
|
32
|
+
begin
|
33
|
+
api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil)
|
34
|
+
raise 'API token is required' unless api_token
|
35
|
+
|
36
|
+
raise 'Bundle ID is required' unless @options[:bundle_id]
|
37
|
+
raise 'App version is required' unless @options[:app_version]
|
38
|
+
|
39
|
+
@network ||= EmergeCLI::Network.new(api_token:, base_url: EMERGE_ORDER_FILE_URL)
|
40
|
+
output_name = @options[:output] || "#{@options[:bundle_id]}-#{@options[:app_version]}.gz"
|
41
|
+
output_name = "#{output_name}.gz" unless output_name.end_with?('.gz')
|
42
|
+
|
43
|
+
Sync do
|
44
|
+
request = get_order_file(options[:bundle_id], options[:app_version])
|
45
|
+
response = request.read
|
46
|
+
|
47
|
+
File.write(output_name, response)
|
48
|
+
|
49
|
+
if @options[:unzip]
|
50
|
+
Logger.info 'Unzipping order file...'
|
51
|
+
Zlib::GzipReader.open(output_name) do |gz|
|
52
|
+
File.write(output_name.gsub('.gz', ''), gz.read)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
Logger.info 'Order file downloaded successfully'
|
57
|
+
end
|
58
|
+
rescue StandardError => e
|
59
|
+
Logger.error "Failed to download order file: #{e.message}"
|
60
|
+
Logger.error 'Check your parameters and try again'
|
61
|
+
raise e
|
62
|
+
ensure
|
63
|
+
@network&.close
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
def get_order_file(bundle_id, app_version)
|
70
|
+
@network.get(
|
71
|
+
path: "/#{bundle_id}/#{app_version}",
|
72
|
+
max_retries: 0
|
73
|
+
)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'dry/cli'
|
2
|
+
require 'cfpropertylist'
|
3
|
+
|
4
|
+
module EmergeCLI
|
5
|
+
module Commands
|
6
|
+
class ValidateLinkmaps < EmergeCLI::Commands::GlobalOptions
|
7
|
+
desc 'Validate linkmaps in xcarchive'
|
8
|
+
|
9
|
+
option :path, type: :string, required: true, desc: 'Path to the xcarchive to validate'
|
10
|
+
|
11
|
+
def initialize(network: nil)
|
12
|
+
@network = network
|
13
|
+
end
|
14
|
+
|
15
|
+
def call(**options)
|
16
|
+
@options = options
|
17
|
+
before(options)
|
18
|
+
|
19
|
+
Sync do
|
20
|
+
executable_name = get_executable_name
|
21
|
+
raise 'Executable not found' if executable_name.nil?
|
22
|
+
|
23
|
+
Logger.info "Using executable: #{executable_name}"
|
24
|
+
|
25
|
+
linkmaps_path = File.join(@options[:path], 'Linkmaps')
|
26
|
+
raise 'Linkmaps folder not found' unless File.directory?(linkmaps_path)
|
27
|
+
|
28
|
+
linkmaps = Dir.glob("#{linkmaps_path}/*.txt")
|
29
|
+
raise 'No linkmaps found' if linkmaps.empty?
|
30
|
+
|
31
|
+
executable_linkmaps = linkmaps.select do |linkmap|
|
32
|
+
File.basename(linkmap).start_with?(executable_name)
|
33
|
+
end
|
34
|
+
raise 'No linkmaps found for executable' if executable_linkmaps.empty?
|
35
|
+
|
36
|
+
Logger.info "✅ Found linkmaps for #{executable_name}"
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
private
|
41
|
+
|
42
|
+
def get_executable_name
|
43
|
+
raise 'Path must be an xcarchive' unless @options[:path].end_with?('.xcarchive')
|
44
|
+
|
45
|
+
app_path = Dir.glob("#{@options[:path]}/Products/Applications/*.app").first
|
46
|
+
info_path = File.join(app_path, 'Info.plist')
|
47
|
+
plist_data = File.read(info_path)
|
48
|
+
plist = CFPropertyList::List.new(data: plist_data)
|
49
|
+
parsed_data = CFPropertyList.native_types(plist.value)
|
50
|
+
|
51
|
+
parsed_data['CFBundleExecutable']
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,201 @@
|
|
1
|
+
require 'dry/cli'
|
2
|
+
require 'json'
|
3
|
+
require 'tty-prompt'
|
4
|
+
|
5
|
+
module EmergeCLI
|
6
|
+
module Commands
|
7
|
+
class Reaper < EmergeCLI::Commands::GlobalOptions
|
8
|
+
desc 'Analyze dead code from an Emerge upload'
|
9
|
+
|
10
|
+
option :upload_id, type: :string, required: true, desc: 'Upload ID to analyze'
|
11
|
+
option :project_root, type: :string, required: true,
|
12
|
+
desc: 'Root directory of the project, defaults to current directory'
|
13
|
+
|
14
|
+
option :api_token, type: :string, required: false,
|
15
|
+
desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]'
|
16
|
+
|
17
|
+
option :profile, type: :boolean, default: false, desc: 'Enable performance profiling metrics'
|
18
|
+
|
19
|
+
option :skip_delete_usages, type: :boolean, default: false,
|
20
|
+
desc: 'Skip deleting usages of the type (experimental feature)'
|
21
|
+
|
22
|
+
def initialize(network: nil)
|
23
|
+
@network = network
|
24
|
+
end
|
25
|
+
|
26
|
+
def call(**options)
|
27
|
+
@options = options
|
28
|
+
@profiler = EmergeCLI::Profiler.new(enabled: options[:profile])
|
29
|
+
@prompt = TTY::Prompt.new
|
30
|
+
before(options)
|
31
|
+
success = false
|
32
|
+
|
33
|
+
begin
|
34
|
+
api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil)
|
35
|
+
raise 'API token is required' unless api_token
|
36
|
+
|
37
|
+
@network ||= EmergeCLI::Network.new(api_token:)
|
38
|
+
project_root = @options[:project_root] || Dir.pwd
|
39
|
+
|
40
|
+
Sync do
|
41
|
+
all_data = @profiler.measure('fetch_dead_code') { fetch_all_dead_code(@options[:upload_id]) }
|
42
|
+
result = @profiler.measure('parse_dead_code') { DeadCodeResult.new(all_data) }
|
43
|
+
|
44
|
+
Logger.info result.to_s
|
45
|
+
|
46
|
+
selected_types = prompt_class_selection(result.filtered_unseen_classes, result.metadata['platform'])
|
47
|
+
Logger.info 'Selected classes:'
|
48
|
+
selected_types.each do |selected_class|
|
49
|
+
Logger.info " - #{selected_class['class_name']}"
|
50
|
+
end
|
51
|
+
|
52
|
+
confirmed = confirm_deletion(selected_types.length)
|
53
|
+
if !confirmed
|
54
|
+
Logger.info 'Operation cancelled'
|
55
|
+
return false
|
56
|
+
end
|
57
|
+
|
58
|
+
Logger.info 'Proceeding with deletion...'
|
59
|
+
platform = result.metadata['platform']
|
60
|
+
deleter = EmergeCLI::Reaper::CodeDeleter.new(
|
61
|
+
project_root: project_root,
|
62
|
+
platform: platform,
|
63
|
+
profiler: @profiler,
|
64
|
+
skip_delete_usages: options[:skip_delete_usages]
|
65
|
+
)
|
66
|
+
@profiler.measure('delete_types') { deleter.delete_types(selected_types) }
|
67
|
+
end
|
68
|
+
|
69
|
+
@profiler.report if @options[:profile]
|
70
|
+
success = true
|
71
|
+
rescue StandardError => e
|
72
|
+
Logger.error "Failed to analyze dead code: #{e.message}"
|
73
|
+
raise e
|
74
|
+
ensure
|
75
|
+
@network&.close
|
76
|
+
end
|
77
|
+
|
78
|
+
success
|
79
|
+
end
|
80
|
+
|
81
|
+
private
|
82
|
+
|
83
|
+
def fetch_all_dead_code(upload_id)
|
84
|
+
Logger.info 'Fetching dead code analysis (this may take a while for large codebases)...'
|
85
|
+
|
86
|
+
page = 1
|
87
|
+
combined_data = nil
|
88
|
+
|
89
|
+
loop do
|
90
|
+
response = fetch_dead_code_page(upload_id, page)
|
91
|
+
data = JSON.parse(response.read)
|
92
|
+
|
93
|
+
if combined_data.nil?
|
94
|
+
combined_data = data
|
95
|
+
else
|
96
|
+
combined_data['dead_code'].concat(data.fetch('dead_code', []))
|
97
|
+
end
|
98
|
+
|
99
|
+
current_page = data.dig('pagination', 'current_page')
|
100
|
+
total_pages = data.dig('pagination', 'total_pages')
|
101
|
+
|
102
|
+
break unless current_page && total_pages && current_page < total_pages
|
103
|
+
|
104
|
+
page += 1
|
105
|
+
Logger.info "Fetching page #{page} of #{total_pages}..."
|
106
|
+
end
|
107
|
+
|
108
|
+
combined_data
|
109
|
+
end
|
110
|
+
|
111
|
+
def fetch_dead_code_page(upload_id, page)
|
112
|
+
@network.post(
|
113
|
+
path: '/deadCode/export',
|
114
|
+
query: {
|
115
|
+
uploadId: upload_id,
|
116
|
+
page: page
|
117
|
+
},
|
118
|
+
headers: { 'Accept' => 'application/json' },
|
119
|
+
body: nil
|
120
|
+
)
|
121
|
+
end
|
122
|
+
|
123
|
+
def prompt_class_selection(unseen_classes, platform)
|
124
|
+
return nil if unseen_classes.empty?
|
125
|
+
|
126
|
+
choices = unseen_classes.map do |item|
|
127
|
+
display_name = if item['paths']&.first && platform == 'ios'
|
128
|
+
"#{item['class_name']} (#{item['paths'].first})"
|
129
|
+
else
|
130
|
+
item['class_name']
|
131
|
+
end
|
132
|
+
{
|
133
|
+
name: display_name,
|
134
|
+
value: item
|
135
|
+
}
|
136
|
+
end
|
137
|
+
|
138
|
+
@prompt.multi_select(
|
139
|
+
'Select classes to delete:'.blue,
|
140
|
+
choices,
|
141
|
+
per_page: 15,
|
142
|
+
echo: false,
|
143
|
+
filter: true,
|
144
|
+
min: 1
|
145
|
+
)
|
146
|
+
end
|
147
|
+
|
148
|
+
def confirm_deletion(count)
|
149
|
+
@prompt.yes?("Are you sure you want to delete #{count} type#{count > 1 ? 's' : ''}?")
|
150
|
+
end
|
151
|
+
|
152
|
+
class DeadCodeResult
|
153
|
+
attr_reader :metadata, :dead_code, :counts, :pagination
|
154
|
+
|
155
|
+
def initialize(data)
|
156
|
+
@metadata = data['metadata']
|
157
|
+
@dead_code = data['dead_code']
|
158
|
+
@counts = data['counts']
|
159
|
+
@pagination = data['pagination']
|
160
|
+
end
|
161
|
+
|
162
|
+
def filtered_unseen_classes
|
163
|
+
@filtered_unseen_classes ||= dead_code
|
164
|
+
.reject { |item| item['seen'] }
|
165
|
+
.reject do |item|
|
166
|
+
paths = item['paths']
|
167
|
+
next false if paths.nil? || paths.empty?
|
168
|
+
|
169
|
+
next true if paths.any? do |path|
|
170
|
+
path.include?('/SourcePackages/checkouts/') ||
|
171
|
+
path.include?('/Pods/') ||
|
172
|
+
path.include?('/Carthage/') ||
|
173
|
+
path.include?('/Vendor/') ||
|
174
|
+
path.include?('/Sources/') ||
|
175
|
+
path.include?('/DerivedSources/')
|
176
|
+
end
|
177
|
+
|
178
|
+
next false if paths.none? do |path|
|
179
|
+
path.end_with?('.swift', '.java', '.kt')
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def to_s
|
185
|
+
<<~SUMMARY.yellow
|
186
|
+
|
187
|
+
Dead Code Analysis Results:
|
188
|
+
App ID: #{@metadata['app_id']}
|
189
|
+
App Version: #{@metadata['version']}
|
190
|
+
Platform: #{@metadata['platform']}
|
191
|
+
|
192
|
+
Statistics:
|
193
|
+
- Total User Sessions: #{@counts['user_sessions']}
|
194
|
+
- Seen Classes: #{@counts['seen_classes']}
|
195
|
+
- Unseen Classes: #{@counts['unseen_classes']}
|
196
|
+
SUMMARY
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
end
|
201
|
+
end
|
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'dry/cli'
|
2
|
+
require 'json'
|
3
|
+
require 'uri'
|
4
|
+
require 'yaml'
|
5
|
+
require 'cfpropertylist'
|
6
|
+
|
7
|
+
module EmergeCLI
|
8
|
+
module Commands
|
9
|
+
module Snapshots
|
10
|
+
class ValidateApp < EmergeCLI::Commands::GlobalOptions
|
11
|
+
desc 'Validate app for snapshot testing [iOS, macOS]'
|
12
|
+
|
13
|
+
# Optional options
|
14
|
+
option :path, type: :string, required: true, desc: 'Path to the app binary or xcarchive'
|
15
|
+
|
16
|
+
# Mangled names are deterministic, no need to demangle them
|
17
|
+
SWIFT_PREVIEWS_MANGLED_NAMES = [
|
18
|
+
'_$s21DeveloperToolsSupport15PreviewRegistryMp',
|
19
|
+
'_$s7SwiftUI15PreviewProviderMp'
|
20
|
+
].freeze
|
21
|
+
|
22
|
+
def call(**options)
|
23
|
+
@options = options
|
24
|
+
before(options)
|
25
|
+
|
26
|
+
Sync do
|
27
|
+
binary_path = get_binary_path
|
28
|
+
Logger.info "Found binary: #{binary_path}"
|
29
|
+
|
30
|
+
Logger.info "Loading binary: #{binary_path}"
|
31
|
+
macho_parser = MachOParser.new
|
32
|
+
macho_parser.load_binary(binary_path)
|
33
|
+
|
34
|
+
use_chained_fixups, imported_symbols = macho_parser.read_linkedit_data_command
|
35
|
+
bound_symbols = macho_parser.read_dyld_info_only_command
|
36
|
+
|
37
|
+
found = macho_parser.find_protocols_in_swift_proto(use_chained_fixups, imported_symbols, bound_symbols,
|
38
|
+
SWIFT_PREVIEWS_MANGLED_NAMES)
|
39
|
+
|
40
|
+
if found
|
41
|
+
Logger.info '✅ Found SwiftUI previews'
|
42
|
+
else
|
43
|
+
Logger.error '❌ No SwiftUI previews found'
|
44
|
+
end
|
45
|
+
found
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def get_binary_path
|
52
|
+
return @options[:path] unless @options[:path].end_with?('.xcarchive')
|
53
|
+
app_path = Dir.glob("#{@options[:path]}/Products/Applications/*.app").first
|
54
|
+
info_path = File.join(app_path, 'Info.plist')
|
55
|
+
plist_data = File.read(info_path)
|
56
|
+
plist = CFPropertyList::List.new(data: plist_data)
|
57
|
+
parsed_data = CFPropertyList.native_types(plist.value)
|
58
|
+
|
59
|
+
File.join(app_path, parsed_data['CFBundleExecutable'])
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
data/lib/emerge_cli.rb
CHANGED
@@ -9,8 +9,13 @@ require_relative 'commands/upload/snapshots/client_libraries/default'
|
|
9
9
|
require_relative 'commands/integrate/fastlane'
|
10
10
|
require_relative 'commands/config/snapshots/snapshots_ios'
|
11
11
|
require_relative 'commands/config/orderfiles/orderfiles_ios'
|
12
|
+
require_relative 'commands/reaper/reaper'
|
13
|
+
require_relative 'commands/snapshots/validate_app'
|
14
|
+
require_relative 'commands/order_files/download_order_files'
|
15
|
+
require_relative 'commands/order_files/validate_linkmaps'
|
12
16
|
|
13
17
|
require_relative 'reaper/ast_parser'
|
18
|
+
require_relative 'reaper/code_deleter'
|
14
19
|
|
15
20
|
require_relative 'utils/git_info_provider'
|
16
21
|
require_relative 'utils/git_result'
|
@@ -20,10 +25,10 @@ require_relative 'utils/logger'
|
|
20
25
|
require_relative 'utils/network'
|
21
26
|
require_relative 'utils/profiler'
|
22
27
|
require_relative 'utils/project_detector'
|
28
|
+
require_relative 'utils/macho_parser'
|
29
|
+
require_relative 'utils/version_check'
|
23
30
|
|
24
31
|
require 'dry/cli'
|
25
|
-
require 'pry'
|
26
|
-
require 'pry-byebug'
|
27
32
|
|
28
33
|
module EmergeCLI
|
29
34
|
extend Dry::CLI::Registry
|
@@ -40,6 +45,17 @@ module EmergeCLI
|
|
40
45
|
prefix.register 'snapshots-ios', Commands::Config::SnapshotsIOS
|
41
46
|
prefix.register 'order-files-ios', Commands::Config::OrderFilesIOS
|
42
47
|
end
|
48
|
+
|
49
|
+
register 'reaper', Commands::Reaper
|
50
|
+
|
51
|
+
register 'snapshots' do |prefix|
|
52
|
+
prefix.register 'validate-app-ios', Commands::Snapshots::ValidateApp
|
53
|
+
end
|
54
|
+
|
55
|
+
register 'order-files' do |prefix|
|
56
|
+
prefix.register 'download', Commands::DownloadOrderFiles
|
57
|
+
prefix.register 'validate-linkmaps', Commands::ValidateLinkmaps
|
58
|
+
end
|
43
59
|
end
|
44
60
|
|
45
61
|
# By default the log level is INFO, but can be overridden by the --debug flag
|