Skip to content

Commit 24b07ca

Browse files
authored
Merge branch 'main' into telkins/objc
2 parents 12302e6 + 489bb65 commit 24b07ca

File tree

29 files changed

+905
-31
lines changed

29 files changed

+905
-31
lines changed

.github/workflows/cli_tests.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ jobs:
1212
runs-on: ubuntu-latest
1313
steps:
1414
- uses: actions/checkout@v4
15+
with:
16+
fetch-depth: 0
1517

1618
- name: Set up Ruby
1719
uses: ruby/setup-ruby@v1
@@ -32,6 +34,8 @@ jobs:
3234

3335
steps:
3436
- uses: actions/checkout@v4
37+
with:
38+
fetch-depth: 0
3539

3640
- name: Set up Ruby
3741
uses: ruby/setup-ruby@v1
@@ -50,6 +54,8 @@ jobs:
5054
runs-on: ubuntu-latest
5155
steps:
5256
- uses: actions/checkout@v4
57+
with:
58+
fetch-depth: 0
5359

5460
- name: Set up Ruby
5561
uses: ruby/setup-ruby@v1

README.md

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,33 @@ emerge upload snapshots \
108108
--project-root /my/awesomeapp/android/repo
109109
```
110110

111-
## Building
111+
## Reaper
112112

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**.
114114

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.

emerge_cli.gemspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ Gem::Specification.new do |spec|
3030
spec.require_paths = ['lib']
3131

3232
spec.add_dependency 'async-http', '~> 0.86.0'
33+
spec.add_dependency 'CFPropertyList', '~> 2.3', '>= 2.3.2'
3334
spec.add_dependency 'chunky_png', '~> 1.4.0'
3435
spec.add_dependency 'dry-cli', '~> 1.2.0'
3536
spec.add_dependency 'open3', '~> 0.2.1'
37+
spec.add_dependency 'ruby-macho', '~> 4.1.0'
3638
spec.add_dependency 'ruby_tree_sitter', '~> 1.9'
3739
spec.add_dependency 'tty-prompt', '~> 0.23.1'
3840
spec.add_dependency 'tty-table', '~> 0.12.0'

lib/commands/global_options.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ class GlobalOptions < Dry::CLI::Command
99
def before(args)
1010
log_level = args[:debug] ? ::Logger::DEBUG : ::Logger::INFO
1111
EmergeCLI::Logger.configure(log_level)
12+
13+
EmergeCLI::Utils::VersionCheck.new.check_version
1214
end
1315
end
1416
end
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/commands/reaper/reaper.rb

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ def call(**options)
3838
project_root = @options[:project_root] || Dir.pwd
3939

4040
Sync do
41-
response = @profiler.measure('fetch_dead_code') { fetch_dead_code(@options[:upload_id]) }
42-
result = @profiler.measure('parse_dead_code') { DeadCodeResult.new(JSON.parse(response.read)) }
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) }
4343

4444
Logger.info result.to_s
4545

@@ -80,11 +80,41 @@ def call(**options)
8080

8181
private
8282

83-
def fetch_dead_code(upload_id)
84-
Logger.info 'Fetching dead code analysis...'
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)
85112
@network.post(
86113
path: '/deadCode/export',
87-
query: { uploadId: upload_id },
114+
query: {
115+
uploadId: upload_id,
116+
page: page
117+
},
88118
headers: { 'Accept' => 'application/json' },
89119
body: nil
90120
)
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

lib/emerge_cli.rb

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
require_relative 'commands/config/snapshots/snapshots_ios'
1111
require_relative 'commands/config/orderfiles/orderfiles_ios'
1212
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'
1316

1417
require_relative 'reaper/ast_parser'
1518
require_relative 'reaper/code_deleter'
@@ -22,10 +25,10 @@
2225
require_relative 'utils/network'
2326
require_relative 'utils/profiler'
2427
require_relative 'utils/project_detector'
28+
require_relative 'utils/macho_parser'
29+
require_relative 'utils/version_check'
2530

2631
require 'dry/cli'
27-
require 'pry'
28-
require 'pry-byebug'
2932

3033
module EmergeCLI
3134
extend Dry::CLI::Registry
@@ -44,6 +47,15 @@ module EmergeCLI
4447
end
4548

4649
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
4759
end
4860

4961
# By default the log level is INFO, but can be overridden by the --debug flag

0 commit comments

Comments
 (0)