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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4a44399f9df18a4ce4d32984e12eaadb2fa921c34409d8e4daa0dd1b4acddd2c
4
- data.tar.gz: 855d965c1478cbc23eee27f5f99281d1a144b7c40197bdb693fac7d93f0be72d
3
+ metadata.gz: e4ada02f9680b03cfcf2d4350d0aa719ae8c1a0399e7783b468b341e2eac5143
4
+ data.tar.gz: 7dfc873c8ba7cb9dd145b7ae181cdcadbe494741ac6f241fcdd0cb48f3c88526
5
5
  SHA512:
6
- metadata.gz: 9d005834cf05f3d463df75af935e5e5ad075fe98cac3a05169df0e9f29c50366731f99b606af924ec33afce6fe332cd0e0ebccdd4768d52fa2dff59628e28b68
7
- data.tar.gz: 184aad1844eacdf8aa3693ab5ff45acb36f11a16d039f258137f807f355f822b5f31565450927f5b6db453300785df287dcb5dc7c68227d635ccc7dc498a83ba
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
- ## Building
111
+ ## Reaper
112
112
 
113
- This depends on [Tree Sitter](https://tree-sitter.github.io/tree-sitter/) for part of its functionality.
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
- In order to parse language grammars for Swift and Kotlin, both of which are third-party language grammars, we also depend on [tsdl](https://github.com/stackmystack/tsdl). This downloads and compiles the language grammars into dylibs for us to use.
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.
@@ -9,6 +9,8 @@ module EmergeCLI
9
9
  def before(args)
10
10
  log_level = args[:debug] ? ::Logger::DEBUG : ::Logger::INFO
11
11
  EmergeCLI::Logger.configure(log_level)
12
+
13
+ EmergeCLI::Utils::VersionCheck.new.check_version
12
14
  end
13
15
  end
14
16
  end
@@ -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