emerge 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ require 'dry/cli'
2
+ require 'xcodeproj'
3
+
4
+ module EmergeCLI
5
+ module Commands
6
+ class ValidateXcodeProject < EmergeCLI::Commands::GlobalOptions
7
+ desc 'Validate xcodeproject for order files'
8
+
9
+ option :path, type: :string, required: true, desc: 'Path to the xcodeproject to validate'
10
+ option :target, type: :string, required: false, desc: 'Target to validate'
11
+ option :build_configuration, type: :string, required: false,
12
+ desc: 'Build configuration to validate (Release by default)'
13
+
14
+ # Constants
15
+ LINK_MAPS_CONFIG = 'LD_GENERATE_MAP_FILE'.freeze
16
+ LINK_MAPS_PATH = 'LD_MAP_FILE_PATH'.freeze
17
+ PATH_TO_LINKMAP = '$(TARGET_TEMP_DIR)/$(PRODUCT_NAME)-LinkMap-$(CURRENT_VARIANT)-$(CURRENT_ARCH).txt'.freeze
18
+
19
+ def call(**options)
20
+ @options = options
21
+ before(options)
22
+
23
+ raise 'Path must be an xcodeproject' unless @options[:path].end_with?('.xcodeproj')
24
+ raise 'Path does not exist' unless File.exist?(@options[:path])
25
+
26
+ @options[:build_configuration] ||= 'Release'
27
+
28
+ Sync do
29
+ project = Xcodeproj::Project.open(@options[:path])
30
+
31
+ validate_xcproj(project)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def validate_xcproj(project)
38
+ project.targets.each do |target|
39
+ next if @options[:target] && target.name != @options[:target]
40
+ next unless target.product_type == 'com.apple.product-type.application'
41
+
42
+ target.build_configurations.each do |config|
43
+ next if config.name != @options[:build_configuration]
44
+ validate_target_config(target, config)
45
+ end
46
+ end
47
+ end
48
+
49
+ def validate_target_config(target, config)
50
+ has_error = false
51
+ if config.build_settings[LINK_MAPS_CONFIG] != 'YES'
52
+ has_error = true
53
+ Logger.error "❌ Write Link Map File (#{LINK_MAPS_CONFIG}) is not set to YES"
54
+ end
55
+ if config.build_settings[LINK_MAPS_PATH] != ''
56
+ has_error = true
57
+ Logger.error "❌ Path to Link Map File (#{LINK_MAPS_PATH}) is not set, we recommend \
58
+ setting it to '#{PATH_TO_LINKMAP}'"
59
+ end
60
+
61
+ if has_error
62
+ Logger.error "❌ Target '#{target.name}' has errors, this means \
63
+ that the linkmaps will not be generated as expected"
64
+ Logger.error "Use `emerge configure order-files-ios --project-path '#{@options[:path]}'` to fix this"
65
+ else
66
+ Logger.info "✅ Target '#{target.name}' is valid"
67
+ end
68
+ end
69
+ end
70
+ end
71
+ 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
@@ -0,0 +1,140 @@
1
+ require 'dry/cli'
2
+ require 'json'
3
+ require 'uri'
4
+ require 'async'
5
+ require 'async/barrier'
6
+ require 'async/semaphore'
7
+ require 'async/http/internet/instance'
8
+
9
+ module EmergeCLI
10
+ module Commands
11
+ module Upload
12
+ class Build < EmergeCLI::Commands::GlobalOptions
13
+ desc 'Upload a build to Emerge'
14
+
15
+ option :path, type: :string, required: true, desc: 'Path to the build artifact'
16
+
17
+ # Optional options
18
+ option :api_token, type: :string, required: false,
19
+ desc: 'API token for authentication, defaults to ENV[EMERGE_API_TOKEN]'
20
+ option :sha, type: :string, required: false, desc: 'SHA of the commit'
21
+ option :branch, type: :string, required: false, desc: 'Branch name'
22
+ option :repo_name, type: :string, required: false, desc: 'Repository name'
23
+ option :base_sha, type: :string, required: false, desc: 'Base SHA'
24
+ option :previous_sha, type: :string, required: false, desc: 'Previous SHA'
25
+ option :pr_number, type: :string, required: false, desc: 'PR number'
26
+
27
+ def initialize(network: nil, git_info_provider: nil)
28
+ @network = network
29
+ @git_info_provider = git_info_provider
30
+ end
31
+
32
+ def call(**options)
33
+ @options = options
34
+ @profiler = EmergeCLI::Profiler.new(enabled: options[:profile])
35
+ before(options)
36
+
37
+ start_time = Time.now
38
+
39
+ file_path = options[:path]
40
+ file_exists = File.exist?(file_path)
41
+ raise "File not found at path: #{file_path}" unless file_exists
42
+
43
+ file_extension = File.extname(file_path)
44
+ raise "Unsupported file type: #{file_extension}" unless ['.ipa', '.apk', '.aab',
45
+ '.zip'].include?(file_extension)
46
+
47
+ api_token = @options[:api_token] || ENV.fetch('EMERGE_API_TOKEN', nil)
48
+ raise 'API token is required and cannot be blank' if api_token.nil? || api_token.strip.empty?
49
+
50
+ @network ||= EmergeCLI::Network.new(api_token:)
51
+ @git_info_provider ||= GitInfoProvider.new
52
+
53
+ Sync do
54
+ upload_url, upload_id = fetch_upload_url
55
+
56
+ file_size = File.size(file_path)
57
+ Logger.info("Uploading file... (#{file_size} bytes)")
58
+
59
+ File.open(file_path, 'rb') do |file|
60
+ headers = {
61
+ 'Content-Type' => 'application/zip',
62
+ 'Content-Length' => file_size.to_s
63
+ }
64
+
65
+ response = @network.put(
66
+ path: upload_url,
67
+ body: file.read,
68
+ headers: headers
69
+ )
70
+
71
+ unless response.status == 200
72
+ Logger.error("Upload failed with status #{response.status}")
73
+ Logger.error("Response body: #{response.body}")
74
+ raise "Uploading file failed with status #{response.status}"
75
+ end
76
+ end
77
+
78
+ Logger.info('Upload complete successfully!')
79
+ Logger.info "Time taken: #{(Time.now - start_time).round(2)} seconds"
80
+ Logger.info("✅ You can view the build analysis at https://emergetools.com/build/#{upload_id}")
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def fetch_upload_url
87
+ git_result = @git_info_provider.fetch_git_info
88
+ sha = @options[:sha] || git_result.sha
89
+ branch = @options[:branch] || git_result.branch
90
+ base_sha = @options[:base_sha] || git_result.base_sha
91
+ previous_sha = @options[:previous_sha] || git_result.previous_sha
92
+ pr_number = @options[:pr_number] || git_result.pr_number
93
+
94
+ # TODO: Make optional
95
+ raise 'SHA is required' unless sha
96
+ raise 'Branch is required' unless branch
97
+
98
+ payload = {
99
+ sha:,
100
+ branch:,
101
+ repo_name: @options[:repo_name],
102
+ # Optional
103
+ base_sha:,
104
+ previous_sha:,
105
+ pr_number: pr_number&.to_s
106
+ }.compact
107
+
108
+ upload_response = @network.post(
109
+ path: '/upload',
110
+ body: payload,
111
+ headers: { 'Content-Type' => 'application/json' }
112
+ )
113
+ upload_json = parse_response(upload_response)
114
+ upload_id = upload_json.fetch('upload_id')
115
+ upload_url = upload_json.fetch('uploadURL')
116
+ Logger.debug("Got upload ID: #{upload_id}")
117
+
118
+ warning = upload_json['warning']
119
+ Logger.warn(warning) if warning
120
+
121
+ [upload_url, upload_id]
122
+ end
123
+
124
+ def parse_response(response)
125
+ case response.status
126
+ when 200
127
+ JSON.parse(response.read)
128
+ when 400
129
+ error_message = JSON.parse(response.read)['errorMessage']
130
+ raise "Invalid parameters: #{error_message}"
131
+ when 401, 403
132
+ raise 'Invalid API token'
133
+ else
134
+ raise "Creating upload failed with status #{response.status}"
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
data/lib/emerge_cli.rb CHANGED
@@ -10,6 +10,16 @@ require_relative 'commands/integrate/fastlane'
10
10
  require_relative 'commands/config/snapshots/snapshots_ios'
11
11
  require_relative 'commands/config/orderfiles/orderfiles_ios'
12
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'
16
+ require_relative 'commands/order_files/validate_xcode_project'
17
+ require_relative 'commands/upload/build'
18
+ require_relative 'commands/build_distribution/validate_app'
19
+ require_relative 'commands/build_distribution/download_and_install'
20
+ require_relative 'commands/autofixes/minify_strings'
21
+ require_relative 'commands/autofixes/strip_binary_symbols'
22
+ require_relative 'commands/autofixes/exported_symbols'
13
23
 
14
24
  require_relative 'reaper/ast_parser'
15
25
  require_relative 'reaper/code_deleter'
@@ -22,6 +32,7 @@ require_relative 'utils/logger'
22
32
  require_relative 'utils/network'
23
33
  require_relative 'utils/profiler'
24
34
  require_relative 'utils/project_detector'
35
+ require_relative 'utils/macho_parser'
25
36
  require_relative 'utils/version_check'
26
37
 
27
38
  require 'dry/cli'
@@ -30,6 +41,7 @@ module EmergeCLI
30
41
  extend Dry::CLI::Registry
31
42
 
32
43
  register 'upload', aliases: ['u'] do |prefix|
44
+ prefix.register 'build', Commands::Upload::Build
33
45
  prefix.register 'snapshots', Commands::Upload::Snapshots
34
46
  end
35
47
 
@@ -43,6 +55,27 @@ module EmergeCLI
43
55
  end
44
56
 
45
57
  register 'reaper', Commands::Reaper
58
+
59
+ register 'snapshots' do |prefix|
60
+ prefix.register 'validate-app-ios', Commands::Snapshots::ValidateApp
61
+ end
62
+
63
+ register 'order-files' do |prefix|
64
+ prefix.register 'download', Commands::DownloadOrderFiles
65
+ prefix.register 'validate-linkmaps', Commands::ValidateLinkmaps
66
+ prefix.register 'validate-xcode-project', Commands::ValidateXcodeProject
67
+ end
68
+
69
+ register 'build-distribution' do |prefix|
70
+ prefix.register 'validate-app', Commands::BuildDistribution::ValidateApp
71
+ prefix.register 'install', Commands::BuildDistribution::DownloadAndInstall
72
+ end
73
+
74
+ register 'autofix' do |prefix|
75
+ prefix.register 'minify-strings', Commands::Autofixes::MinifyStrings
76
+ prefix.register 'strip-binary-symbols', Commands::Autofixes::StripBinarySymbols
77
+ prefix.register 'exported-symbols', Commands::Autofixes::ExportedSymbols
78
+ end
46
79
  end
47
80
 
48
81
  # By default the log level is INFO, but can be overridden by the --debug flag
@@ -9,19 +9,22 @@ module EmergeCLI
9
9
  DECLARATION_NODE_TYPES = {
10
10
  'swift' => %i[class_declaration protocol_declaration],
11
11
  'kotlin' => %i[class_declaration protocol_declaration interface_declaration object_declaration],
12
- 'java' => %i[class_declaration protocol_declaration interface_declaration]
12
+ 'java' => %i[class_declaration protocol_declaration interface_declaration],
13
+ 'objc' => %i[class_declaration protocol_declaration class_implementation class_interface]
13
14
  }.freeze
14
15
 
15
16
  IDENTIFIER_NODE_TYPES = {
16
17
  'swift' => %i[simple_identifier qualified_name identifier type_identifier],
17
18
  'kotlin' => %i[simple_identifier qualified_name identifier type_identifier],
18
- 'java' => %i[simple_identifier qualified_name identifier type_identifier]
19
+ 'java' => %i[simple_identifier qualified_name identifier type_identifier],
20
+ 'objc' => %i[simple_identifier qualified_name identifier type_identifier]
19
21
  }.freeze
20
22
 
21
23
  COMMENT_AND_IMPORT_NODE_TYPES = {
22
24
  'swift' => %i[comment import_declaration],
23
25
  'kotlin' => %i[comment import_header],
24
- 'java' => %i[comment import_declaration]
26
+ 'java' => %i[comment import_declaration],
27
+ 'objc' => %i[comment import_declaration preproc_include]
25
28
  }.freeze
26
29
 
27
30
  attr_reader :parser, :language
@@ -52,17 +55,9 @@ module EmergeCLI
52
55
  extension = platform == 'darwin' ? 'dylib' : 'so'
53
56
  parser_file = "libtree-sitter-#{language}-#{platform}-#{arch}.#{extension}"
54
57
  parser_path = File.join('parsers', parser_file)
58
+ raise "No language grammar found for #{language}" unless File.exist?(parser_path)
55
59
 
56
- case language
57
- when 'swift'
58
- @parser.language = TreeSitter::Language.load('swift', parser_path)
59
- when 'kotlin'
60
- @parser.language = TreeSitter::Language.load('kotlin', parser_path)
61
- when 'java'
62
- @parser.language = TreeSitter::Language.load('java', parser_path)
63
- else
64
- raise "Unsupported language: #{language}"
65
- end
60
+ @parser.language = TreeSitter::Language.load(language, parser_path)
66
61
  end
67
62
 
68
63
  # Deletes a type from the given file contents.
@@ -127,6 +122,7 @@ module EmergeCLI
127
122
 
128
123
  while (node = nodes_to_process.shift)
129
124
  identifier_type = identifier_node_types.include?(node.type)
125
+ Logger.debug "Processing node: #{node.type} #{node_text(node)}"
130
126
  declaration_type = if node == tree.root_node
131
127
  false
132
128
  else
@@ -136,6 +132,11 @@ module EmergeCLI
136
132
  usages << { line: node.start_point.row, usage_type: 'declaration' }
137
133
  elsif identifier_type && node_text(node) == type_name
138
134
  usages << { line: node.start_point.row, usage_type: 'identifier' }
135
+ elsif node.type == :@implementation
136
+ next_sibling = node.next_named_sibling
137
+ if next_sibling.type == :identifier && node_text(next_sibling) == type_name
138
+ usages << { line: next_sibling.start_point.row, usage_type: 'declaration' }
139
+ end
139
140
  end
140
141
 
141
142
  node.each { |child| nodes_to_process.push(child) }
@@ -172,14 +173,14 @@ module EmergeCLI
172
173
 
173
174
  return file_contents if nodes_to_remove.empty?
174
175
 
175
- Logger.debug "Found #{nodes_to_remove.length} nodes to remove"
176
+ Logger.debug "Found #{nodes_to_remove.length} nodes to remove"
176
177
  remove_nodes_from_content(file_contents, nodes_to_remove)
177
178
  end
178
179
 
179
180
  private
180
181
 
181
182
  def remove_node(node, lines_to_remove)
182
- Logger.debug "Removing node: #{node.type}"
183
+ Logger.debug "Removing node: #{node.type}"
183
184
  start_position = node.start_point.row
184
185
  end_position = node.end_point.row
185
186
  lines_to_remove << { start: start_position, end: end_position }
@@ -287,7 +288,7 @@ module EmergeCLI
287
288
  when :navigation_expression # NetworkDebugger.printStats
288
289
  result = handle_navigation_expression(current)
289
290
  return result if result
290
- when :class_declaration, :function_declaration, :method_declaration
291
+ when :class_declaration, :function_declaration, :method_declaration, :@implementation
291
292
  Logger.debug "Reached structural element, stopping at: #{current.type}"
292
293
  break
293
294
  end
@@ -163,7 +163,8 @@ module EmergeCLI
163
163
  found_usages = []
164
164
  source_patterns = case @platform&.downcase
165
165
  when 'ios'
166
- { 'swift' => '**/*.swift' }
166
+ { 'swift' => '**/*.swift',
167
+ 'objc' => '**/*.{m,h}' }
167
168
  when 'android'
168
169
  {
169
170
  'kotlin' => '**/*.kt',
@@ -253,6 +254,7 @@ module EmergeCLI
253
254
  when '.swift' then 'swift'
254
255
  when '.kt' then 'kotlin'
255
256
  when '.java' then 'java'
257
+ when '.m', '.h' then 'objc'
256
258
  else
257
259
  raise "Unsupported file type for #{file_path}"
258
260
  end
data/lib/utils/git.rb CHANGED
@@ -3,6 +3,7 @@ require 'open3'
3
3
  module EmergeCLI
4
4
  module Git
5
5
  def self.branch
6
+ Logger.debug 'Getting current branch name'
6
7
  command = 'git rev-parse --abbrev-ref HEAD'
7
8
  Logger.debug command
8
9
  stdout, _, status = Open3.capture3(command)
@@ -39,6 +40,7 @@ module EmergeCLI
39
40
  end
40
41
 
41
42
  def self.sha
43
+ Logger.debug 'Getting current SHA'
42
44
  command = 'git rev-parse HEAD'
43
45
  Logger.debug command
44
46
  stdout, _, status = Open3.capture3(command)
@@ -46,6 +48,7 @@ module EmergeCLI
46
48
  end
47
49
 
48
50
  def self.base_sha
51
+ Logger.debug 'Getting base SHA'
49
52
  current_branch = branch
50
53
  remote_head = remote_head_branch
51
54
  return nil if current_branch.nil? || remote_head.nil?
@@ -59,19 +62,34 @@ module EmergeCLI
59
62
  end
60
63
 
61
64
  def self.previous_sha
65
+ Logger.debug 'Getting previous SHA'
66
+ command = 'git rev-list --count HEAD'
67
+ Logger.debug command
68
+ count_stdout, _, count_status = Open3.capture3(command)
69
+
70
+ if !count_status.success? || count_stdout.strip.to_i <= 1
71
+ Logger.error 'Detected shallow clone while trying to get the previous commit. ' \
72
+ 'Please clone with full history using: git clone --no-single-branch ' \
73
+ 'or configure CI with fetch-depth: 0'
74
+ return nil
75
+ end
76
+
62
77
  command = 'git rev-parse HEAD^'
63
78
  Logger.debug command
64
- stdout, _, status = Open3.capture3(command)
79
+ stdout, stderr, status = Open3.capture3(command)
80
+ Logger.error "Failed to get previous SHA: #{stdout}, #{stderr}" if !status.success?
65
81
  stdout.strip if status.success?
66
82
  end
67
83
 
68
84
  def self.primary_remote
85
+ Logger.debug 'Getting primary remote'
69
86
  remote = remote()
70
87
  return nil if remote.nil?
71
88
  remote.include?('origin') ? 'origin' : remote.first
72
89
  end
73
90
 
74
91
  def self.remote_head_branch(remote = primary_remote)
92
+ Logger.debug 'Getting remote head branch'
75
93
  return nil if remote.nil?
76
94
  command = "git remote show #{remote}"
77
95
  Logger.debug command
@@ -86,6 +104,7 @@ module EmergeCLI
86
104
  end
87
105
 
88
106
  def self.remote_url(remote = primary_remote)
107
+ Logger.debug 'Getting remote URL'
89
108
  return nil if remote.nil?
90
109
  command = "git config --get remote.#{remote}.url"
91
110
  Logger.debug command
@@ -94,6 +113,7 @@ module EmergeCLI
94
113
  end
95
114
 
96
115
  def self.remote
116
+ Logger.debug 'Getting remote'
97
117
  command = 'git remote'
98
118
  Logger.debug command
99
119
  stdout, _, status = Open3.capture3(command)