emerge 0.2.2 → 0.3.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/reaper/reaper.rb +201 -0
- data/lib/emerge_cli.rb +5 -2
- data/lib/reaper/ast_parser.rb +188 -3
- data/lib/reaper/code_deleter.rb +263 -0
- data/lib/utils/network.rb +11 -6
- 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 +12 -31
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f78e2be6724a6620a5135d20910b99fe76c8a959b00f6703bdad0c0d2c043742
|
4
|
+
data.tar.gz: 8366d036be25ec0b3da721346b1c71dcfcdccd2bf7b7f2bea9f3e6838cf62a8b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 4359ea66070503dc5dc98d1040a39598df3d760bad78393724baa0f689caf18c5e26d2875375ba3751f2fb95d4641e078ff4111084c7acf51b63118955e6a3cc
|
7
|
+
data.tar.gz: 13d4cc4f14a5f6e794592ce12eb5f6acd785fa5d51fbb7d9241e69c78ab7317a5f7b374dbcf06b2bf641cff890b1a7baa941ad3c76f306e207bbc069492875f0
|
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,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
|
data/lib/emerge_cli.rb
CHANGED
@@ -9,8 +9,10 @@ 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'
|
12
13
|
|
13
14
|
require_relative 'reaper/ast_parser'
|
15
|
+
require_relative 'reaper/code_deleter'
|
14
16
|
|
15
17
|
require_relative 'utils/git_info_provider'
|
16
18
|
require_relative 'utils/git_result'
|
@@ -20,10 +22,9 @@ require_relative 'utils/logger'
|
|
20
22
|
require_relative 'utils/network'
|
21
23
|
require_relative 'utils/profiler'
|
22
24
|
require_relative 'utils/project_detector'
|
25
|
+
require_relative 'utils/version_check'
|
23
26
|
|
24
27
|
require 'dry/cli'
|
25
|
-
require 'pry'
|
26
|
-
require 'pry-byebug'
|
27
28
|
|
28
29
|
module EmergeCLI
|
29
30
|
extend Dry::CLI::Registry
|
@@ -40,6 +41,8 @@ module EmergeCLI
|
|
40
41
|
prefix.register 'snapshots-ios', Commands::Config::SnapshotsIOS
|
41
42
|
prefix.register 'order-files-ios', Commands::Config::OrderFilesIOS
|
42
43
|
end
|
44
|
+
|
45
|
+
register 'reaper', Commands::Reaper
|
43
46
|
end
|
44
47
|
|
45
48
|
# By default the log level is INFO, but can be overridden by the --debug flag
|
data/lib/reaper/ast_parser.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
require 'tree_sitter'
|
2
2
|
|
3
|
-
module
|
3
|
+
module EmergeCLI
|
4
4
|
module Reaper
|
5
5
|
# Parses the AST of a given file using Tree Sitter and allows us to find usages or delete types.
|
6
6
|
# This does have a lot of limitations since it only looks at a single file at a time,
|
@@ -8,7 +8,7 @@ module Emerge
|
|
8
8
|
class AstParser
|
9
9
|
DECLARATION_NODE_TYPES = {
|
10
10
|
'swift' => %i[class_declaration protocol_declaration],
|
11
|
-
'kotlin' => %i[class_declaration protocol_declaration interface_declaration],
|
11
|
+
'kotlin' => %i[class_declaration protocol_declaration interface_declaration object_declaration],
|
12
12
|
'java' => %i[class_declaration protocol_declaration interface_declaration]
|
13
13
|
}.freeze
|
14
14
|
|
@@ -76,6 +76,7 @@ module Emerge
|
|
76
76
|
lines_to_remove = []
|
77
77
|
|
78
78
|
while (node = nodes_to_process.shift)
|
79
|
+
Logger.debug "Processing node: #{node.type} #{node_text(node)}"
|
79
80
|
if declaration_node_types.include?(node.type)
|
80
81
|
type_identifier_node = find_type_identifier(node)
|
81
82
|
if type_identifier_node && fully_qualified_type_name(type_identifier_node) == type_name
|
@@ -95,6 +96,7 @@ module Emerge
|
|
95
96
|
|
96
97
|
lines = file_contents.split("\n")
|
97
98
|
lines_to_remove.each do |range|
|
99
|
+
Logger.debug "Removing lines #{range[:start]} to #{range[:end]}"
|
98
100
|
(range[:start]..range[:end]).each { |i| lines[i] = nil }
|
99
101
|
|
100
102
|
# Remove extra newline after class declaration, but only if it's blank
|
@@ -107,7 +109,11 @@ module Emerge
|
|
107
109
|
new_tree = @parser.parse_string(nil, modified_source)
|
108
110
|
|
109
111
|
return nil if only_comments_and_imports?(TreeSitter::TreeCursor.new(new_tree.root_node))
|
110
|
-
|
112
|
+
|
113
|
+
# Preserve original newline state
|
114
|
+
had_final_newline = file_contents.end_with?("\n")
|
115
|
+
modified_source = modified_source.rstrip
|
116
|
+
had_final_newline ? "#{modified_source}\n" : modified_source
|
111
117
|
end
|
112
118
|
|
113
119
|
# Finds all usages of a given type in a file.
|
@@ -138,9 +144,42 @@ module Emerge
|
|
138
144
|
usages
|
139
145
|
end
|
140
146
|
|
147
|
+
def delete_usage(file_contents:, type_name:)
|
148
|
+
@current_file_contents = file_contents
|
149
|
+
tree = @parser.parse_string(nil, file_contents)
|
150
|
+
cursor = TreeSitter::TreeCursor.new(tree.root_node)
|
151
|
+
nodes_to_process = [cursor.current_node]
|
152
|
+
nodes_to_remove = []
|
153
|
+
|
154
|
+
Logger.debug "Starting to scan for usages of #{type_name}"
|
155
|
+
|
156
|
+
while (node = nodes_to_process.shift)
|
157
|
+
identifier_type = identifier_node_types.include?(node.type)
|
158
|
+
if identifier_type && node_text(node) == type_name
|
159
|
+
Logger.debug "Found usage of #{type_name} in node type: #{node.type}"
|
160
|
+
removable_node = find_removable_parent(node)
|
161
|
+
if removable_node
|
162
|
+
Logger.debug "Will remove parent node of type: #{removable_node.type}"
|
163
|
+
Logger.debug "Node text to remove: #{node_text(removable_node)}"
|
164
|
+
nodes_to_remove << removable_node
|
165
|
+
else
|
166
|
+
Logger.debug 'No suitable parent node found for removal'
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
node.each { |child| nodes_to_process.push(child) }
|
171
|
+
end
|
172
|
+
|
173
|
+
return file_contents if nodes_to_remove.empty?
|
174
|
+
|
175
|
+
Logger.debug "Found #{nodes_to_remove.length} nodes to remove"
|
176
|
+
remove_nodes_from_content(file_contents, nodes_to_remove)
|
177
|
+
end
|
178
|
+
|
141
179
|
private
|
142
180
|
|
143
181
|
def remove_node(node, lines_to_remove)
|
182
|
+
Logger.debug "Removing node: #{node.type}"
|
144
183
|
start_position = node.start_point.row
|
145
184
|
end_position = node.end_point.row
|
146
185
|
lines_to_remove << { start: start_position, end: end_position }
|
@@ -188,6 +227,7 @@ module Emerge
|
|
188
227
|
parent = find_parent_type_declaration(parent)
|
189
228
|
end
|
190
229
|
|
230
|
+
Logger.debug "Fully qualified type name: #{class_name}"
|
191
231
|
class_name
|
192
232
|
end
|
193
233
|
|
@@ -229,6 +269,151 @@ module Emerge
|
|
229
269
|
end_byte = node.end_byte
|
230
270
|
@current_file_contents[start_byte...end_byte]
|
231
271
|
end
|
272
|
+
|
273
|
+
def find_removable_parent(node)
|
274
|
+
current = node
|
275
|
+
Logger.debug "Finding removable parent for node type: #{node.type}"
|
276
|
+
|
277
|
+
while current && !current.null?
|
278
|
+
Logger.debug "Checking parent node type: #{current.type}"
|
279
|
+
case current.type
|
280
|
+
when :variable_declaration, # var foo: DeletedType
|
281
|
+
:parameter, # func example(param: DeletedType)
|
282
|
+
:type_annotation, # : DeletedType
|
283
|
+
:argument, # functionCall(param: DeletedType)
|
284
|
+
:import_declaration # import DeletedType
|
285
|
+
Logger.debug "Found removable parent node of type: #{current.type}"
|
286
|
+
return current
|
287
|
+
when :navigation_expression # NetworkDebugger.printStats
|
288
|
+
result = handle_navigation_expression(current)
|
289
|
+
return result if result
|
290
|
+
when :class_declaration, :function_declaration, :method_declaration
|
291
|
+
Logger.debug "Reached structural element, stopping at: #{current.type}"
|
292
|
+
break
|
293
|
+
end
|
294
|
+
current = current.parent
|
295
|
+
end
|
296
|
+
|
297
|
+
Logger.debug 'No better parent found, returning original node'
|
298
|
+
node
|
299
|
+
end
|
300
|
+
|
301
|
+
def handle_navigation_expression(navigation_node)
|
302
|
+
# If this navigation expression is part of a call, remove the entire call
|
303
|
+
parent_call = navigation_node.parent
|
304
|
+
return nil unless parent_call && parent_call.type == :call_expression
|
305
|
+
|
306
|
+
Logger.debug 'Found call expression containing navigation expression'
|
307
|
+
# Check if this call is the only statement in an if condition
|
308
|
+
if_statement = find_parent_if_statement(parent_call)
|
309
|
+
if if_statement && contains_single_statement?(if_statement)
|
310
|
+
Logger.debug 'Found if statement with single call, removing entire if block'
|
311
|
+
return if_statement
|
312
|
+
end
|
313
|
+
parent_call
|
314
|
+
end
|
315
|
+
|
316
|
+
def find_parent_if_statement(node)
|
317
|
+
current = node
|
318
|
+
Logger.debug "Looking for parent if statement starting from node type: #{node.type}"
|
319
|
+
while current && !current.null?
|
320
|
+
Logger.debug " Checking node type: #{current.type}"
|
321
|
+
if current.type == :if_statement
|
322
|
+
Logger.debug ' Found parent if statement'
|
323
|
+
return current
|
324
|
+
end
|
325
|
+
current = current.parent
|
326
|
+
end
|
327
|
+
Logger.debug ' No parent if statement found'
|
328
|
+
nil
|
329
|
+
end
|
330
|
+
|
331
|
+
def contains_single_statement?(if_statement)
|
332
|
+
Logger.debug 'Checking if statement for single statement'
|
333
|
+
# Find the block/body of the if statement - try different field names based on language
|
334
|
+
block = if_statement.child_by_field_name('consequence') ||
|
335
|
+
if_statement.child_by_field_name('body') ||
|
336
|
+
if_statement.find { |child| child.type == :statements }
|
337
|
+
|
338
|
+
unless block
|
339
|
+
Logger.debug ' No block found in if statement. Node structure:'
|
340
|
+
Logger.debug " If statement type: #{if_statement.type}"
|
341
|
+
Logger.debug ' Children types:'
|
342
|
+
if_statement.each do |child|
|
343
|
+
Logger.debug " - #{child.type} (text: #{node_text(child)[0..50]}...)"
|
344
|
+
end
|
345
|
+
return false
|
346
|
+
end
|
347
|
+
|
348
|
+
Logger.debug " Found block of type: #{block.type}"
|
349
|
+
|
350
|
+
relevant_children = block.reject do |child|
|
351
|
+
%i[comment line_break whitespace].include?(child.type)
|
352
|
+
end
|
353
|
+
|
354
|
+
Logger.debug " Found #{relevant_children.length} significant children in if block"
|
355
|
+
relevant_children.each do |child|
|
356
|
+
Logger.debug " Child type: #{child.type}, text: #{node_text(child)[0..50]}..."
|
357
|
+
end
|
358
|
+
|
359
|
+
relevant_children.length == 1
|
360
|
+
end
|
361
|
+
|
362
|
+
def remove_nodes_from_content(content, nodes)
|
363
|
+
# Sort nodes by their position in reverse order to avoid offset issues
|
364
|
+
nodes.sort_by! { |n| -n.start_byte }
|
365
|
+
|
366
|
+
# Check if original file had final newline
|
367
|
+
had_final_newline = content.end_with?("\n")
|
368
|
+
|
369
|
+
# Remove each node and clean up surrounding blank lines
|
370
|
+
modified_contents = content.dup
|
371
|
+
nodes.each do |node|
|
372
|
+
modified_contents = remove_single_node(modified_contents, node)
|
373
|
+
end
|
374
|
+
|
375
|
+
# Restore the original newline state at the end of the file
|
376
|
+
modified_contents.chomp!
|
377
|
+
had_final_newline ? "#{modified_contents}\n" : modified_contents
|
378
|
+
end
|
379
|
+
|
380
|
+
def remove_single_node(content, node)
|
381
|
+
had_final_newline = content.end_with?("\n")
|
382
|
+
|
383
|
+
# Remove the node's content
|
384
|
+
start_byte = node.start_byte
|
385
|
+
end_byte = node.end_byte
|
386
|
+
Logger.debug "Removing text: #{content[start_byte...end_byte]}"
|
387
|
+
content[start_byte...end_byte] = ''
|
388
|
+
|
389
|
+
# Clean up any blank lines created by the removal
|
390
|
+
content = cleanup_blank_lines(content, node.start_point.row, node.end_point.row)
|
391
|
+
|
392
|
+
had_final_newline ? "#{content}\n" : content
|
393
|
+
end
|
394
|
+
|
395
|
+
def cleanup_blank_lines(content, start_line, end_line)
|
396
|
+
lines = content.split("\n")
|
397
|
+
|
398
|
+
# Check for consecutive blank lines around the removed content
|
399
|
+
lines[start_line - 1] = nil if consecutive_blank_lines?(lines, start_line, end_line)
|
400
|
+
|
401
|
+
# Remove any blank lines left in the removed node's place
|
402
|
+
(start_line..end_line).each do |i|
|
403
|
+
lines[i] = nil if lines[i]&.match?(/^\s*$/)
|
404
|
+
end
|
405
|
+
|
406
|
+
lines.compact.join("\n")
|
407
|
+
end
|
408
|
+
|
409
|
+
def consecutive_blank_lines?(lines, start_line, end_line)
|
410
|
+
return false unless start_line > 0 && end_line + 1 < lines.length
|
411
|
+
|
412
|
+
prev_line = lines[start_line - 1]
|
413
|
+
next_line = lines[end_line + 1]
|
414
|
+
|
415
|
+
prev_line&.match?(/^\s*$/) && next_line&.match?(/^\s*$/)
|
416
|
+
end
|
232
417
|
end
|
233
418
|
end
|
234
419
|
end
|
@@ -0,0 +1,263 @@
|
|
1
|
+
require 'xcodeproj'
|
2
|
+
|
3
|
+
module EmergeCLI
|
4
|
+
module Reaper
|
5
|
+
class CodeDeleter
|
6
|
+
def initialize(project_root:, platform:, profiler:, skip_delete_usages: false)
|
7
|
+
@project_root = File.expand_path(project_root)
|
8
|
+
@platform = platform
|
9
|
+
@profiler = profiler
|
10
|
+
@skip_delete_usages = skip_delete_usages
|
11
|
+
Logger.debug "Initialized CodeDeleter with project root: #{@project_root}, platform: #{@platform}"
|
12
|
+
end
|
13
|
+
|
14
|
+
def delete_types(types)
|
15
|
+
Logger.debug "Project root: #{@project_root}"
|
16
|
+
|
17
|
+
types.each do |class_info|
|
18
|
+
Logger.info "Deleting #{class_info['class_name']}"
|
19
|
+
|
20
|
+
type_name = parse_type_name(class_info['class_name'])
|
21
|
+
Logger.debug "Parsed type name: #{type_name}"
|
22
|
+
|
23
|
+
# Remove line number from path if present
|
24
|
+
paths = class_info['paths']&.map { |path| path.sub(/:\d+$/, '') }
|
25
|
+
found_usages = @profiler.measure('find_type_in_project') do
|
26
|
+
find_type_in_project(type_name)
|
27
|
+
end
|
28
|
+
|
29
|
+
if paths.nil? || paths.empty?
|
30
|
+
Logger.info "No paths provided for #{type_name}, using found usages instead..."
|
31
|
+
paths = found_usages
|
32
|
+
.select { |usage| usage[:usages].any? { |u| u[:usage_type] == 'declaration' } }
|
33
|
+
.map { |usage| usage[:path] }
|
34
|
+
if paths.empty?
|
35
|
+
Logger.warn "Could not find any files containing #{type_name}"
|
36
|
+
next
|
37
|
+
end
|
38
|
+
Logger.info "Found #{type_name} in: #{paths.join(', ')}"
|
39
|
+
end
|
40
|
+
|
41
|
+
# First pass: Delete declarations
|
42
|
+
paths.each do |path|
|
43
|
+
Logger.debug "Processing path: #{path}"
|
44
|
+
@profiler.measure('delete_type_from_file') do
|
45
|
+
delete_type_from_file(path, type_name)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Second pass: Delete remaining usages (unless skipped)
|
50
|
+
if @skip_delete_usages
|
51
|
+
Logger.info 'Skipping delete usages'
|
52
|
+
else
|
53
|
+
identifier_usages = found_usages.select do |usage|
|
54
|
+
usage[:usages].any? { |u| u[:usage_type] == 'identifier' }
|
55
|
+
end
|
56
|
+
identifier_usage_paths = identifier_usages.map { |usage| usage[:path] }.uniq
|
57
|
+
if identifier_usage_paths.empty?
|
58
|
+
Logger.info 'No identifier usages found, skipping delete usages'
|
59
|
+
else
|
60
|
+
identifier_usage_paths.each do |path|
|
61
|
+
Logger.debug "Processing usages in path: #{path}"
|
62
|
+
@profiler.measure('delete_usages_from_file') do
|
63
|
+
delete_usages_from_file(path, type_name)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def parse_type_name(type_name)
|
74
|
+
# Remove first module prefix for Swift types if present
|
75
|
+
if @platform == 'ios' && type_name.include?('.')
|
76
|
+
type_name.split('.')[1..].join('.')
|
77
|
+
# For Android, strip package name and just use the class name
|
78
|
+
elsif @platform == 'android' && type_name.include?('.')
|
79
|
+
# rubocop:disable Layout/LineLength
|
80
|
+
# Handle cases like "com.emergetools.hackernews.data.remote.ItemResponse $NullResponse (HackerNewsBaseClient.kt)"
|
81
|
+
# rubocop:enable Layout/LineLength
|
82
|
+
has_nested_class = type_name.include?('$')
|
83
|
+
parts = type_name.split
|
84
|
+
if parts.length == 0
|
85
|
+
type_name
|
86
|
+
elsif has_nested_class && parts.length > 1
|
87
|
+
base_name = parts[0].split('.').last
|
88
|
+
nested_class = parts[1].match(/\$(.+)/).captures.first
|
89
|
+
"#{base_name}.#{nested_class}"
|
90
|
+
else
|
91
|
+
parts[0].split('.').last
|
92
|
+
end
|
93
|
+
else
|
94
|
+
type_name
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
def delete_type_from_file(path, type_name)
|
99
|
+
full_path = resolve_file_path(path)
|
100
|
+
return unless full_path
|
101
|
+
|
102
|
+
Logger.debug "Processing file: #{full_path}"
|
103
|
+
begin
|
104
|
+
original_contents = @profiler.measure('read_file') { File.read(full_path) }
|
105
|
+
parser = make_parser_for_file(full_path)
|
106
|
+
modified_contents = @profiler.measure('parse_and_delete_type') do
|
107
|
+
parser.delete_type(
|
108
|
+
file_contents: original_contents,
|
109
|
+
type_name: type_name
|
110
|
+
)
|
111
|
+
end
|
112
|
+
|
113
|
+
if modified_contents.nil?
|
114
|
+
@profiler.measure('delete_file') do
|
115
|
+
File.delete(full_path)
|
116
|
+
end
|
117
|
+
if parser.language == 'swift'
|
118
|
+
@profiler.measure('delete_type_from_xcode_project') do
|
119
|
+
delete_type_from_xcode_project(full_path)
|
120
|
+
end
|
121
|
+
end
|
122
|
+
Logger.info "Deleted file #{full_path} as it only contained #{type_name}"
|
123
|
+
elsif modified_contents != original_contents
|
124
|
+
@profiler.measure('write_file') do
|
125
|
+
File.write(full_path, modified_contents)
|
126
|
+
end
|
127
|
+
Logger.info "Successfully deleted #{type_name} from #{full_path}"
|
128
|
+
else
|
129
|
+
Logger.warn "No changes made to #{full_path} for #{type_name}"
|
130
|
+
end
|
131
|
+
rescue StandardError => e
|
132
|
+
Logger.error "Failed to delete #{type_name} from #{full_path}: #{e.message}"
|
133
|
+
Logger.error e.backtrace.join("\n")
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def delete_type_from_xcode_project(file_path)
|
138
|
+
xcodeproj_path = Dir.glob(File.join(@project_root, '**/*.xcodeproj')).first
|
139
|
+
if xcodeproj_path.nil?
|
140
|
+
Logger.warn "No Xcode project found in #{@project_root}"
|
141
|
+
return
|
142
|
+
end
|
143
|
+
|
144
|
+
begin
|
145
|
+
project = Xcodeproj::Project.open(xcodeproj_path)
|
146
|
+
relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(@project_root)).to_s
|
147
|
+
|
148
|
+
file_ref = project.files.find { |f| f.real_path.to_s.end_with?(relative_path) }
|
149
|
+
if file_ref
|
150
|
+
file_ref.remove_from_project
|
151
|
+
project.save
|
152
|
+
Logger.info "Removed #{relative_path} from Xcode project"
|
153
|
+
else
|
154
|
+
Logger.warn "Could not find #{relative_path} in Xcode project"
|
155
|
+
end
|
156
|
+
rescue StandardError => e
|
157
|
+
Logger.error "Failed to update Xcode project: #{e.message}"
|
158
|
+
Logger.error e.backtrace.join("\n")
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
def find_type_in_project(type_name)
|
163
|
+
found_usages = []
|
164
|
+
source_patterns = case @platform&.downcase
|
165
|
+
when 'ios'
|
166
|
+
{ 'swift' => '**/*.swift' }
|
167
|
+
when 'android'
|
168
|
+
{
|
169
|
+
'kotlin' => '**/*.kt',
|
170
|
+
'java' => '**/*.java'
|
171
|
+
}
|
172
|
+
else
|
173
|
+
raise "Unsupported platform: #{@platform}"
|
174
|
+
end
|
175
|
+
|
176
|
+
source_patterns.each do |language, pattern|
|
177
|
+
Dir.glob(File.join(@project_root, pattern)).reject { |path| path.include?('/build/') }.each do |file_path|
|
178
|
+
Logger.debug "Scanning #{file_path} for #{type_name}"
|
179
|
+
contents = File.read(file_path)
|
180
|
+
parser = make_parser_for_file(file_path)
|
181
|
+
usages = parser.find_usages(file_contents: contents, type_name: type_name)
|
182
|
+
|
183
|
+
if usages.any?
|
184
|
+
Logger.debug "✅ Found #{type_name} in #{file_path}"
|
185
|
+
relative_path = Pathname.new(file_path).relative_path_from(Pathname.new(@project_root)).to_s
|
186
|
+
found_usages << {
|
187
|
+
path: relative_path,
|
188
|
+
usages: usages,
|
189
|
+
language: language
|
190
|
+
}
|
191
|
+
end
|
192
|
+
rescue StandardError => e
|
193
|
+
Logger.warn "Error scanning #{file_path}: #{e.message}"
|
194
|
+
end
|
195
|
+
end
|
196
|
+
|
197
|
+
found_usages
|
198
|
+
end
|
199
|
+
|
200
|
+
def delete_usages_from_file(path, type_name)
|
201
|
+
full_path = resolve_file_path(path)
|
202
|
+
return unless full_path
|
203
|
+
|
204
|
+
begin
|
205
|
+
original_contents = File.read(full_path)
|
206
|
+
parser = make_parser_for_file(full_path)
|
207
|
+
Logger.debug "Deleting usages of #{type_name} from #{full_path}"
|
208
|
+
modified_contents = parser.delete_usage(
|
209
|
+
file_contents: original_contents,
|
210
|
+
type_name: type_name
|
211
|
+
)
|
212
|
+
|
213
|
+
if modified_contents != original_contents
|
214
|
+
File.write(full_path, modified_contents)
|
215
|
+
Logger.info "Successfully removed usages of #{type_name} from #{full_path}"
|
216
|
+
end
|
217
|
+
rescue StandardError => e
|
218
|
+
Logger.error "Failed to delete usages of #{type_name} from #{full_path}: #{e.message}"
|
219
|
+
Logger.error e.backtrace.join("\n")
|
220
|
+
end
|
221
|
+
end
|
222
|
+
|
223
|
+
def resolve_file_path(path)
|
224
|
+
# If path starts with /, treat it as relative to project root
|
225
|
+
if path.start_with?('/')
|
226
|
+
path = path[1..] # Remove leading slash
|
227
|
+
full_path = File.join(@project_root, path)
|
228
|
+
return full_path if File.exist?(full_path)
|
229
|
+
end
|
230
|
+
|
231
|
+
# Try direct path first
|
232
|
+
full_path = File.join(@project_root, path)
|
233
|
+
return full_path if File.exist?(full_path)
|
234
|
+
|
235
|
+
# If not found, search recursively
|
236
|
+
Logger.debug "File not found at #{full_path}, searching in project..."
|
237
|
+
matching_files = Dir.glob(File.join(@project_root, '**', path))
|
238
|
+
.reject { |p| p.include?('/build/') }
|
239
|
+
|
240
|
+
if matching_files.empty?
|
241
|
+
Logger.warn "Could not find #{path} in project"
|
242
|
+
return nil
|
243
|
+
elsif matching_files.length > 1
|
244
|
+
Logger.warn "Found multiple matches for #{path}: #{matching_files.join(', ')}"
|
245
|
+
Logger.warn "Using first match: #{matching_files.first}"
|
246
|
+
end
|
247
|
+
|
248
|
+
matching_files.first
|
249
|
+
end
|
250
|
+
|
251
|
+
def make_parser_for_file(file_path)
|
252
|
+
language = case File.extname(file_path)
|
253
|
+
when '.swift' then 'swift'
|
254
|
+
when '.kt' then 'kotlin'
|
255
|
+
when '.java' then 'java'
|
256
|
+
else
|
257
|
+
raise "Unsupported file type for #{file_path}"
|
258
|
+
end
|
259
|
+
AstParser.new(language)
|
260
|
+
end
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
data/lib/utils/network.rb
CHANGED
@@ -11,7 +11,7 @@ module EmergeCLI
|
|
11
11
|
RETRY_DELAY = 5
|
12
12
|
MAX_RETRIES = 3
|
13
13
|
|
14
|
-
def initialize(api_token
|
14
|
+
def initialize(api_token: nil, base_url: EMERGE_API_PROD_URL)
|
15
15
|
@base_url = base_url
|
16
16
|
@api_token = api_token
|
17
17
|
@internet = Async::HTTP::Internet.new
|
@@ -21,8 +21,8 @@ module EmergeCLI
|
|
21
21
|
request(:get, path, nil, headers)
|
22
22
|
end
|
23
23
|
|
24
|
-
def post(path:, body:, headers: {})
|
25
|
-
request(:post, path, body, headers)
|
24
|
+
def post(path:, body:, headers: {}, query: nil)
|
25
|
+
request(:post, path, body, headers, query)
|
26
26
|
end
|
27
27
|
|
28
28
|
def put(path:, body:, headers: {})
|
@@ -35,18 +35,23 @@ module EmergeCLI
|
|
35
35
|
|
36
36
|
private
|
37
37
|
|
38
|
-
def request(method, path, body, custom_headers)
|
38
|
+
def request(method, path, body, custom_headers, query = nil)
|
39
39
|
uri = if path.start_with?('http')
|
40
40
|
URI.parse(path)
|
41
41
|
else
|
42
|
-
URI
|
42
|
+
query_string = query ? URI.encode_www_form(query) : nil
|
43
|
+
URI::HTTPS.build(
|
44
|
+
host: @base_url,
|
45
|
+
path: path,
|
46
|
+
query: query_string
|
47
|
+
)
|
43
48
|
end
|
44
49
|
absolute_uri = uri.to_s
|
45
50
|
|
46
51
|
headers = {
|
47
|
-
'X-API-Token' => @api_token,
|
48
52
|
'User-Agent' => "emerge-cli/#{EmergeCLI::VERSION}"
|
49
53
|
}
|
54
|
+
headers['X-API-Token'] = @api_token if @api_token
|
50
55
|
headers['Content-Type'] = 'application/json' if method == :post && body.is_a?(Hash)
|
51
56
|
headers.merge!(custom_headers)
|
52
57
|
|
@@ -0,0 +1,32 @@
|
|
1
|
+
require 'json'
|
2
|
+
|
3
|
+
module EmergeCLI
|
4
|
+
module Utils
|
5
|
+
class VersionCheck
|
6
|
+
def initialize(network: EmergeCLI::Network.new)
|
7
|
+
@network = network
|
8
|
+
end
|
9
|
+
|
10
|
+
def check_version
|
11
|
+
Sync do
|
12
|
+
response = @network.get(
|
13
|
+
path: 'https://rubygems.org/api/v1/gems/emerge.json',
|
14
|
+
headers: {}
|
15
|
+
)
|
16
|
+
latest_version = JSON.parse(response.read).fetch('version')
|
17
|
+
current_version = EmergeCLI::VERSION
|
18
|
+
|
19
|
+
if Gem::Version.new(latest_version) > Gem::Version.new(current_version)
|
20
|
+
Logger.warn "A new version of emerge-cli is available (#{latest_version})"
|
21
|
+
Logger.warn "You are currently using version #{current_version}"
|
22
|
+
Logger.warn "To update, run: gem update emerge\n"
|
23
|
+
end
|
24
|
+
end
|
25
|
+
rescue KeyError
|
26
|
+
Logger.error 'Failed to parse version from RubyGems API response'
|
27
|
+
rescue StandardError => e
|
28
|
+
Logger.error "Failed to check for updates: #{e.message}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/lib/version.rb
CHANGED
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
Binary file
|
metadata
CHANGED
@@ -1,29 +1,15 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: emerge
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Emerge Tools
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-12-
|
11
|
+
date: 2024-12-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
|
-
- !ruby/object:Gem::Dependency
|
14
|
-
name: async
|
15
|
-
requirement: !ruby/object:Gem::Requirement
|
16
|
-
requirements:
|
17
|
-
- - "~>"
|
18
|
-
- !ruby/object:Gem::Version
|
19
|
-
version: 2.21.1
|
20
|
-
type: :runtime
|
21
|
-
prerelease: false
|
22
|
-
version_requirements: !ruby/object:Gem::Requirement
|
23
|
-
requirements:
|
24
|
-
- - "~>"
|
25
|
-
- !ruby/object:Gem::Version
|
26
|
-
version: 2.21.1
|
27
13
|
- !ruby/object:Gem::Dependency
|
28
14
|
name: async-http
|
29
15
|
requirement: !ruby/object:Gem::Requirement
|
@@ -80,20 +66,6 @@ dependencies:
|
|
80
66
|
- - "~>"
|
81
67
|
- !ruby/object:Gem::Version
|
82
68
|
version: 0.2.1
|
83
|
-
- !ruby/object:Gem::Dependency
|
84
|
-
name: pry-byebug
|
85
|
-
requirement: !ruby/object:Gem::Requirement
|
86
|
-
requirements:
|
87
|
-
- - "~>"
|
88
|
-
- !ruby/object:Gem::Version
|
89
|
-
version: '3.8'
|
90
|
-
type: :runtime
|
91
|
-
prerelease: false
|
92
|
-
version_requirements: !ruby/object:Gem::Requirement
|
93
|
-
requirements:
|
94
|
-
- - "~>"
|
95
|
-
- !ruby/object:Gem::Version
|
96
|
-
version: '3.8'
|
97
69
|
- !ruby/object:Gem::Dependency
|
98
70
|
name: ruby_tree_sitter
|
99
71
|
requirement: !ruby/object:Gem::Requirement
|
@@ -165,6 +137,7 @@ files:
|
|
165
137
|
- lib/commands/config/snapshots/snapshots_ios.rb
|
166
138
|
- lib/commands/global_options.rb
|
167
139
|
- lib/commands/integrate/fastlane.rb
|
140
|
+
- lib/commands/reaper/reaper.rb
|
168
141
|
- lib/commands/upload/snapshots/client_libraries/default.rb
|
169
142
|
- lib/commands/upload/snapshots/client_libraries/paparazzi.rb
|
170
143
|
- lib/commands/upload/snapshots/client_libraries/roborazzi.rb
|
@@ -172,6 +145,7 @@ files:
|
|
172
145
|
- lib/commands/upload/snapshots/snapshots.rb
|
173
146
|
- lib/emerge_cli.rb
|
174
147
|
- lib/reaper/ast_parser.rb
|
148
|
+
- lib/reaper/code_deleter.rb
|
175
149
|
- lib/utils/git.rb
|
176
150
|
- lib/utils/git_info_provider.rb
|
177
151
|
- lib/utils/git_result.rb
|
@@ -180,7 +154,14 @@ files:
|
|
180
154
|
- lib/utils/network.rb
|
181
155
|
- lib/utils/profiler.rb
|
182
156
|
- lib/utils/project_detector.rb
|
157
|
+
- lib/utils/version_check.rb
|
183
158
|
- lib/version.rb
|
159
|
+
- parsers/libtree-sitter-java-darwin-arm64.dylib
|
160
|
+
- parsers/libtree-sitter-java-linux-x86_64.so
|
161
|
+
- parsers/libtree-sitter-kotlin-darwin-arm64.dylib
|
162
|
+
- parsers/libtree-sitter-kotlin-linux-x86_64.so
|
163
|
+
- parsers/libtree-sitter-swift-darwin-arm64.dylib
|
164
|
+
- parsers/libtree-sitter-swift-linux-x86_64.so
|
184
165
|
homepage: https://github.com/EmergeTools/emerge-cli
|
185
166
|
licenses:
|
186
167
|
- MIT
|
@@ -204,7 +185,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
204
185
|
- !ruby/object:Gem::Version
|
205
186
|
version: '0'
|
206
187
|
requirements: []
|
207
|
-
rubygems_version: 3.
|
188
|
+
rubygems_version: 3.5.11
|
208
189
|
signing_key:
|
209
190
|
specification_version: 4
|
210
191
|
summary: Emerge CLI
|