roast-ai 0.1.6 → 0.1.7

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9bdaada020320af4c7fead86184a7cd0b1c06a89e88bc15ac424b31f4b449fb6
4
- data.tar.gz: 3f83f410a1f21992de59b19b8e2f0ea402b9fe50b314b82514723cf20f9a599f
3
+ metadata.gz: 40bb03c596dcaa3dd49685c9b5f804ca6c6db00cf239d5e52748e6fa485f9c3b
4
+ data.tar.gz: c56399cca493aa9d925bb4d0550dc7ad0c0ed03b74b4f7894a3205ba72e66d1e
5
5
  SHA512:
6
- metadata.gz: c6dfdd57104e363735a6e24bf5beb43a042340f582f94d8126c3f8a0d30dd92b031c5201a7d3d136bfcd3b7095a51c49329b08c07c5c19544240b18e20ad9948
7
- data.tar.gz: 02aec8d620c3fe0c57d0d4e06790a7513a4a1aa44f68462721b6aa7da3f22f377598b4c00453e4ec825b0adcac3c335d470f945599d39571edb67e8a380597ac
6
+ metadata.gz: 2560cd52f21eb2aa0c954d1759ac762c12f2840a1533cc41017003a022f002212e9fb8d6e6b3d11fc1245ff413a86bd68d3aad7a142de9a30d4c534277a80f51
7
+ data.tar.gz: 371d573330873f1e76aec168e72ed8e2c53d0a127af83683205fae7d060dfa758aeb2352133b4f8b263803b5384b77be76b91a564d945cfd8966a682959a0d13
data/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.7] - 2024-05-16
9
+
10
+ ### Added
11
+ - `UpdateFiles` tool for applying diffs/patches to multiple files at once
12
+ - Support for atomic file updates with rollback capability
13
+ - Comprehensive documentation for all built-in tools
14
+ - Enhanced README with detailed tool usage examples
15
+
8
16
  ## [0.1.6] - 2024-05-15
9
17
 
10
18
  ### Added
@@ -25,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
25
33
  - Improved initializer loading and error handling
26
34
  - Fixed tests for nested .roast folders
27
35
 
36
+ [0.1.7]: https://github.com/Shopify/roast/compare/v0.1.6...v0.1.7
28
37
  [0.1.6]: https://github.com/Shopify/roast/compare/v0.1.5...v0.1.6
29
38
 
30
39
  ## [0.1.5] - 2024-05-13
data/Gemfile.lock CHANGED
@@ -1,8 +1,9 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- roast-ai (0.1.6)
4
+ roast-ai (0.1.7)
5
5
  activesupport (~> 8.0)
6
+ diff-lcs (~> 1.5)
6
7
  faraday-retry
7
8
  json-schema
8
9
  raix (~> 0.8.4)
@@ -37,6 +38,7 @@ GEM
37
38
  crack (1.0.0)
38
39
  bigdecimal
39
40
  rexml
41
+ diff-lcs (1.6.1)
40
42
  dotenv (3.1.8)
41
43
  drb (2.2.1)
42
44
  event_stream_parser (1.0.0)
data/README.md CHANGED
@@ -385,6 +385,151 @@ For most workflows, you'll mainly use `response` to access the current step's re
385
385
 
386
386
  Roast provides extensive instrumentation capabilities using ActiveSupport::Notifications. You can monitor workflow execution, track AI model usage, measure performance, and integrate with external monitoring systems. [Read the full instrumentation documentation](docs/INSTRUMENTATION.md).
387
387
 
388
+ ### Built-in Tools
389
+
390
+ Roast provides several built-in tools that you can use in your workflows:
391
+
392
+ #### ReadFile
393
+
394
+ Reads the contents of a file from the filesystem.
395
+
396
+ ```ruby
397
+ # Basic usage
398
+ read_file(path: "path/to/file.txt")
399
+
400
+ # Reading a specific portion of a file
401
+ read_file(path: "path/to/large_file.txt", offset: 100, limit: 50)
402
+ ```
403
+
404
+ - The `path` can be absolute or relative to the current working directory
405
+ - Use `offset` and `limit` for large files to read specific sections (line numbers)
406
+ - Returns the file content as a string
407
+
408
+ #### WriteFile
409
+
410
+ Writes content to a file, creating the file if it doesn't exist or overwriting it if it does.
411
+
412
+ ```ruby
413
+ # Basic usage
414
+ write_file(path: "output.txt", content: "This is the file content")
415
+
416
+ # With path restriction for security
417
+ write_file(
418
+ path: "output.txt",
419
+ content: "Restricted content",
420
+ restrict: "/safe/directory" # Only allows writing to files under this path
421
+ )
422
+ ```
423
+
424
+ - Creates missing directories automatically
425
+ - Can restrict file operations to specific directories for security
426
+ - Returns a success message with the number of lines written
427
+
428
+ #### UpdateFiles
429
+
430
+ Applies a unified diff/patch to one or more files. Changes are applied atomically when possible.
431
+
432
+ ```ruby
433
+ update_files(
434
+ diff: <<~DIFF,
435
+ --- a/file1.txt
436
+ +++ b/file1.txt
437
+ @@ -1,3 +1,4 @@
438
+ line1
439
+ +new line
440
+ line2
441
+ line3
442
+
443
+ --- a/file2.txt
444
+ +++ b/file2.txt
445
+ @@ -5,7 +5,7 @@
446
+ line5
447
+ line6
448
+ -old line7
449
+ +updated line7
450
+ line8
451
+ DIFF
452
+ base_path: "/path/to/project", # Optional, defaults to current working directory
453
+ restrict_path: "/path/to/allowed", # Optional, restricts where files can be modified
454
+ create_files: true, # Optional, defaults to true
455
+ )
456
+ ```
457
+
458
+ - Accepts standard unified diff format from tools like `git diff`
459
+ - Supports multiple file changes in a single operation
460
+ - Handles file creation, deletion, and modification
461
+ - Performs atomic operations with rollback on failure
462
+ - Includes fuzzy matching to handle minor context differences
463
+ - This tool is especially useful for making targeted changes to multiple files at once
464
+
465
+ #### Grep
466
+
467
+ Searches file contents for a specific pattern using regular expressions.
468
+
469
+ ```ruby
470
+ # Basic usage
471
+ grep(pattern: "function\\s+myFunction")
472
+
473
+ # With file filtering
474
+ grep(pattern: "class\\s+User", include: "*.rb")
475
+
476
+ # With directory scope
477
+ grep(pattern: "TODO:", path: "src/components")
478
+ ```
479
+
480
+ - Uses regular expressions for powerful pattern matching
481
+ - Can filter by file types using the `include` parameter
482
+ - Can scope searches to specific directories with the `path` parameter
483
+ - Returns a list of files containing matches
484
+
485
+ #### SearchFile
486
+
487
+ Provides advanced file search capabilities beyond basic pattern matching.
488
+
489
+ ```ruby
490
+ search_file(query: "class User", file_path: "app/models")
491
+ ```
492
+
493
+ - Combines pattern matching with contextual search
494
+ - Useful for finding specific code structures or patterns
495
+ - Returns matched lines with context
496
+
497
+ #### Cmd
498
+
499
+ Executes shell commands and returns their output.
500
+
501
+ ```ruby
502
+ # Execute a simple command
503
+ cmd(command: "ls -la")
504
+
505
+ # With working directory specified
506
+ cmd(command: "npm list", cwd: "/path/to/project")
507
+
508
+ # With environment variables
509
+ cmd(command: "deploy", env: { "NODE_ENV" => "production" })
510
+ ```
511
+
512
+ - Provides access to shell commands for more complex operations
513
+ - Can specify working directory and environment variables
514
+ - Captures and returns command output
515
+ - Useful for integrating with existing tools and scripts
516
+
517
+ #### CodingAgent
518
+
519
+ Creates a specialized agent for complex coding tasks or long-running operations.
520
+
521
+ ```ruby
522
+ coding_agent(
523
+ task: "Refactor the authentication module to use JWT tokens",
524
+ language: "ruby",
525
+ files: ["app/models/user.rb", "app/controllers/auth_controller.rb"]
526
+ )
527
+ ```
528
+
529
+ - Delegates complex tasks to a specialized coding agent
530
+ - Useful for tasks that require deep code understanding or multi-step changes
531
+ - Can work across multiple files and languages
532
+
388
533
  ### Custom Tools
389
534
 
390
535
  You can create your own tools using the [Raix function dispatch pattern](https://github.com/OlympiaAI/raix-rails?tab=readme-ov-file#use-of-toolsfunctions). Custom tools should be placed in `.roast/initializers/` (subdirectories are supported):
@@ -199,4 +199,45 @@ Then run:
199
199
  roast execute test_instrumentation.yml some_file.rb
200
200
  ```
201
201
 
202
- Your instrumentation should capture the workflow start, step execution, and workflow completion events.
202
+ Your instrumentation should capture the workflow start, step execution, and workflow completion events.
203
+
204
+ ## Available Tools
205
+
206
+ Roast provides several built-in tools that you can use in your workflows:
207
+
208
+ ### WriteFile Tool
209
+
210
+ Writes content to a file, creating the file if it doesn't exist or overwriting it if it does.
211
+
212
+ ```ruby
213
+ # Example usage in a prompt
214
+ write_file(path: "output.txt", content: "This is the file content")
215
+ ```
216
+
217
+ ### UpdateFiles Tool
218
+
219
+ Applies a unified diff/patch to one or more files. Changes are applied atomically when possible.
220
+
221
+ ```ruby
222
+ # Example usage in a prompt
223
+ update_files(diff: <<~DIFF, base_path: "/path/to/project", create_files: true)
224
+ --- a/file1.txt
225
+ +++ b/file1.txt
226
+ @@ -1,3 +1,4 @@
227
+ line1
228
+ +new line
229
+ line2
230
+ line3
231
+
232
+ --- a/file2.txt
233
+ +++ b/file2.txt
234
+ @@ -5,7 +5,7 @@
235
+ line5
236
+ line6
237
+ -old line7
238
+ +updated line7
239
+ line8
240
+ DIFF
241
+ ```
242
+
243
+ This tool is especially useful for making targeted changes to multiple files at once, without having to replace entire file contents.
@@ -0,0 +1,413 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "roast/helpers/logger"
5
+ require "diff/lcs"
6
+
7
+ module Roast
8
+ module Tools
9
+ module UpdateFiles
10
+ extend self
11
+
12
+ class << self
13
+ # Add this method to be included in other classes
14
+ def included(base)
15
+ base.class_eval do
16
+ function(
17
+ :update_files,
18
+ "Apply a unified diff/patch to files in the workspace. Changes are applied atomically if possible.",
19
+ diff: {
20
+ type: "string",
21
+ description: "The unified diff/patch content to apply",
22
+ },
23
+ base_path: {
24
+ type: "string",
25
+ description: "Base path for relative file paths in the diff (default: current working directory)",
26
+ required: false,
27
+ },
28
+ restrict_path: {
29
+ type: "string",
30
+ description: "Optional path restriction to limit where files can be modified",
31
+ required: false,
32
+ },
33
+ create_files: {
34
+ type: "boolean",
35
+ description: "Whether to create new files if they don't exist (default: true)",
36
+ required: false,
37
+ },
38
+ ) do |params|
39
+ base_path = params[:base_path] || Dir.pwd
40
+ create_files = params.fetch(:create_files, true)
41
+ restrict_path = params[:restrict_path]
42
+
43
+ Roast::Tools::UpdateFiles.call(
44
+ params[:diff],
45
+ base_path,
46
+ restrict_path,
47
+ create_files,
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ # Apply a unified diff to files
55
+ # @param diff [String] unified diff content
56
+ # @param base_path [String] base path for relative paths in the diff
57
+ # @param restrict_path [String, nil] optional path restriction
58
+ # @param create_files [Boolean] whether to create new files if they don't exist
59
+ # @return [String] result message
60
+ def call(diff, base_path = Dir.pwd, restrict_path = nil, create_files = true)
61
+ Roast::Helpers::Logger.info("🔄 Applying patch to files\n")
62
+
63
+ # Parse the unified diff to identify files and changes
64
+ file_changes = parse_unified_diff(diff)
65
+
66
+ if file_changes.empty?
67
+ return "Error: No valid file changes found in the provided diff"
68
+ end
69
+
70
+ # Validate changes
71
+ validation_result = validate_changes(file_changes, base_path, restrict_path, create_files)
72
+ return validation_result if validation_result.is_a?(String) && validation_result.start_with?("Error:")
73
+
74
+ # Apply changes atomically
75
+ apply_changes(file_changes, base_path, create_files)
76
+ rescue StandardError => e
77
+ "Error applying patch: #{e.message}".tap do |error_message|
78
+ Roast::Helpers::Logger.error(error_message + "\n")
79
+ Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
80
+ end
81
+ end
82
+
83
+ private
84
+
85
+ # Parse a unified diff to extract file changes
86
+ # @param diff [String] unified diff content
87
+ # @return [Array<Hash>] array of file change objects
88
+ def parse_unified_diff(diff)
89
+ lines = diff.split("\n")
90
+ file_changes = []
91
+ current_files = { src: nil, dst: nil }
92
+ current_hunks = []
93
+
94
+ i = 0
95
+ while i < lines.length
96
+ line = lines[i]
97
+
98
+ # New file header (--- line followed by +++ line)
99
+ if line.start_with?("--- ") && i + 1 < lines.length && lines[i + 1].start_with?("+++ ")
100
+ # Save previous file if exists
101
+ if current_files[:src] && current_files[:dst] && !current_hunks.empty?
102
+ file_changes << {
103
+ src_path: current_files[:src],
104
+ dst_path: current_files[:dst],
105
+ hunks: current_hunks.dup,
106
+ }
107
+ end
108
+
109
+ # Extract new file paths
110
+ current_files = {
111
+ src: extract_file_path(line),
112
+ dst: extract_file_path(lines[i + 1]),
113
+ }
114
+ current_hunks = []
115
+ i += 2
116
+ next
117
+ end
118
+
119
+ # Hunk header
120
+ if line.match(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/)
121
+ current_hunk = { header: line, changes: [] }
122
+
123
+ # Parse the header
124
+ header_match = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
125
+ if header_match
126
+ current_hunk[:src_start] = header_match[1].to_i
127
+ current_hunk[:src_count] = header_match[2] ? header_match[2].to_i : 1 # rubocop:disable Metrics/BlockNesting
128
+ current_hunk[:dst_start] = header_match[3].to_i
129
+ current_hunk[:dst_count] = header_match[4] ? header_match[4].to_i : 1 # rubocop:disable Metrics/BlockNesting
130
+ end
131
+
132
+ current_hunks << current_hunk
133
+ i += 1
134
+ next
135
+ end
136
+
137
+ # Capture content lines for the current hunk
138
+ if !current_hunks.empty? && line.start_with?("+", "-", " ")
139
+ current_hunks.last[:changes] << line
140
+ end
141
+
142
+ i += 1
143
+ end
144
+
145
+ # Add the last file
146
+ if current_files[:src] && current_files[:dst] && !current_hunks.empty?
147
+ file_changes << {
148
+ src_path: current_files[:src],
149
+ dst_path: current_files[:dst],
150
+ hunks: current_hunks.dup,
151
+ }
152
+ end
153
+
154
+ file_changes
155
+ end
156
+
157
+ # Extract file path from a diff header line
158
+ # @param line [String] diff header line (--- or +++ line)
159
+ # @return [String] file path
160
+ def extract_file_path(line)
161
+ # Handle special cases
162
+ return "/dev/null" if line.include?("/dev/null")
163
+
164
+ # Remove prefixes
165
+ path = line.sub(%r{^(\+\+\+|\-\-\-) (a|b)/}, "")
166
+ # Handle files without 'a/' or 'b/' prefix
167
+ path = line.sub(/^(\+\+\+|\-\-\-) /, "") if path == line
168
+ # Remove timestamps if present
169
+ path = path.sub(/\t.*$/, "")
170
+ path
171
+ end
172
+
173
+ # Validate changes before applying them
174
+ # @param file_changes [Array<Hash>] array of file change objects
175
+ # @param base_path [String] base path for relative paths
176
+ # @param restrict_path [String, nil] optional path restriction
177
+ # @param create_files [Boolean] whether to create new files if they don't exist
178
+ # @return [Boolean, String] true if valid, error message if invalid
179
+ def validate_changes(file_changes, base_path, restrict_path, create_files)
180
+ # Validate each file in the changes
181
+ file_changes.each do |file_change|
182
+ # For destination files (they will be written to)
183
+ if file_change[:dst_path] && file_change[:dst_path] != "/dev/null"
184
+ absolute_path = File.expand_path(file_change[:dst_path], base_path)
185
+
186
+ # Check path restriction
187
+ if restrict_path && !absolute_path.start_with?(restrict_path)
188
+ return "Error: Path #{file_change[:dst_path]} must start with '#{restrict_path}' to use the update_files tool"
189
+ end
190
+
191
+ # Check if file exists
192
+ if !File.exist?(absolute_path) && !create_files
193
+ return "Error: File #{file_change[:dst_path]} does not exist and create_files is false"
194
+ end
195
+
196
+ # Check if file is readable and writable if it exists
197
+ if File.exist?(absolute_path)
198
+ unless File.readable?(absolute_path)
199
+ return "Error: File #{file_change[:dst_path]} is not readable"
200
+ end
201
+
202
+ unless File.writable?(absolute_path)
203
+ return "Error: File #{file_change[:dst_path]} is not writable"
204
+ end
205
+ end
206
+ end
207
+
208
+ # For source files (they will be read from)
209
+ next unless file_change[:src_path] && file_change[:src_path] != "/dev/null" && file_change[:src_path] != file_change[:dst_path]
210
+
211
+ absolute_path = File.expand_path(file_change[:src_path], base_path)
212
+
213
+ # Source file must exist unless it's a new file
214
+ if !File.exist?(absolute_path) && file_change[:src_path] != "/dev/null"
215
+ # Special case for new files (src: /dev/null)
216
+ if file_change[:src_path] != "/dev/null"
217
+ return "Error: Source file #{file_change[:src_path]} does not exist"
218
+ end
219
+ end
220
+
221
+ # Check if file is readable if it exists
222
+ next unless File.exist?(absolute_path)
223
+ unless File.readable?(absolute_path)
224
+ return "Error: Source file #{file_change[:src_path]} is not readable"
225
+ end
226
+ end
227
+
228
+ true
229
+ end
230
+
231
+ # Apply changes to files
232
+ # @param file_changes [Array<Hash>] array of file change objects
233
+ # @param base_path [String] base path for relative paths
234
+ # @param create_files [Boolean] whether to create new files if they don't exist
235
+ # @return [String] result message
236
+ def apply_changes(file_changes, base_path, create_files)
237
+ # Create a temporary backup of all files to be modified
238
+ backup_files = {}
239
+ modified_files = []
240
+
241
+ # Step 1: Create backups
242
+ file_changes.each do |file_change|
243
+ next unless file_change[:dst_path] && file_change[:dst_path] != "/dev/null"
244
+
245
+ absolute_path = File.expand_path(file_change[:dst_path], base_path)
246
+
247
+ if File.exist?(absolute_path)
248
+ backup_files[absolute_path] = File.read(absolute_path)
249
+ end
250
+ end
251
+
252
+ # Step 2: Try to apply all changes
253
+ begin
254
+ file_changes.each do |file_change|
255
+ next unless file_change[:dst_path]
256
+
257
+ # Special case for file deletion
258
+ if file_change[:dst_path] == "/dev/null" && file_change[:src_path] != "/dev/null"
259
+ absolute_src_path = File.expand_path(file_change[:src_path], base_path)
260
+ if File.exist?(absolute_src_path)
261
+ File.delete(absolute_src_path)
262
+ modified_files << file_change[:src_path]
263
+ end
264
+ next
265
+ end
266
+
267
+ # Skip if both src and dst are /dev/null (shouldn't happen but just in case)
268
+ next if file_change[:dst_path] == "/dev/null" && file_change[:src_path] == "/dev/null"
269
+
270
+ absolute_dst_path = File.expand_path(file_change[:dst_path], base_path)
271
+
272
+ # Special case for new files
273
+ if file_change[:src_path] == "/dev/null"
274
+ # For new files, ensure directory exists
275
+ dir = File.dirname(absolute_dst_path)
276
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
277
+
278
+ # Create the file with the added content
279
+ content = []
280
+ file_change[:hunks].each do |hunk|
281
+ hunk[:changes].each do |line|
282
+ content << line[1..-1] if line.start_with?("+")
283
+ end
284
+ end
285
+
286
+ # Write the content
287
+ File.write(absolute_dst_path, content.join("\n") + (content.empty? ? "" : "\n"))
288
+ modified_files << file_change[:dst_path]
289
+ next
290
+ end
291
+
292
+ # Normal case: Modify existing file
293
+ content = ""
294
+ if File.exist?(absolute_dst_path)
295
+ content = File.read(absolute_dst_path)
296
+ else
297
+ # For new files that aren't from /dev/null, ensure directory exists
298
+ dir = File.dirname(absolute_dst_path)
299
+ FileUtils.mkdir_p(dir) unless File.directory?(dir)
300
+ end
301
+ content_lines = content.split("\n")
302
+
303
+ # Apply each hunk to the file
304
+ file_change[:hunks].each do |hunk|
305
+ # Apply the changes to the content
306
+ new_content_lines = apply_hunk(content_lines, hunk)
307
+
308
+ # Check if the hunk was applied successfully
309
+ if new_content_lines
310
+ content_lines = new_content_lines
311
+ else
312
+ raise "Hunk could not be applied cleanly: #{hunk[:header]}"
313
+ end
314
+ end
315
+
316
+ # Write the updated content
317
+ File.write(absolute_dst_path, content_lines.join("\n") + (content_lines.empty? ? "" : "\n"))
318
+ modified_files << file_change[:dst_path]
319
+ end
320
+
321
+ "Successfully applied patch to #{modified_files.size} file(s): #{modified_files.join(", ")}"
322
+ rescue StandardError => e
323
+ # Restore backups if any change fails
324
+ backup_files.each do |path, content|
325
+ File.write(path, content) if File.exist?(path)
326
+ end
327
+
328
+ "Error applying patch: #{e.message}"
329
+ end
330
+ end
331
+
332
+ # Apply a single hunk to file content
333
+ # @param content_lines [Array<String>] lines of file content
334
+ # @param hunk [Hash] hunk information
335
+ # @return [Array<String>, nil] updated content lines or nil if cannot apply
336
+ def apply_hunk(content_lines, hunk)
337
+ # For completely new files with no content
338
+ if content_lines.empty? && hunk[:src_start] == 1 && hunk[:src_count] == 0
339
+ # Just extract the added lines
340
+ return hunk[:changes].select { |line| line.start_with?("+") }.map { |line| line[1..-1] }
341
+ end
342
+
343
+ # For complete file replacement
344
+ if !content_lines.empty? &&
345
+ hunk[:src_start] == 1 &&
346
+ hunk[:changes].count { |line| line.start_with?("-") } >= content_lines.size
347
+ # Get only the added lines for the new content
348
+ return hunk[:changes].select { |line| line.start_with?("+") }.map { |line| line[1..-1] }
349
+ end
350
+
351
+ # Standard case with context matching
352
+ result = content_lines.dup
353
+ src_line = hunk[:src_start] - 1 # 0-based index
354
+ dst_line = hunk[:dst_start] - 1 # 0-based index
355
+
356
+ # Process each change line
357
+ hunk[:changes].each do |line|
358
+ if line.start_with?(" ") # Context line
359
+ # Verify context matches
360
+ if src_line >= result.size || result[src_line] != line[1..-1]
361
+ # Try to find the context nearby (fuzzy matching)
362
+ found = false
363
+ (-3..3).each do |offset|
364
+ check_pos = src_line + offset
365
+ next if check_pos < 0 || check_pos >= result.size
366
+
367
+ next unless result[check_pos] == line[1..-1]
368
+
369
+ src_line = check_pos
370
+ dst_line = check_pos
371
+ found = true
372
+ break
373
+ end
374
+
375
+ return nil unless found # Context doesn't match, cannot apply hunk
376
+ end
377
+
378
+ src_line += 1
379
+ dst_line += 1
380
+ elsif line.start_with?("-") # Removal
381
+ # Verify line exists and matches
382
+ if src_line >= result.size || result[src_line] != line[1..-1]
383
+ # Try to find the line nearby (fuzzy matching)
384
+ found = false
385
+ (-3..3).each do |offset|
386
+ check_pos = src_line + offset
387
+ next if check_pos < 0 || check_pos >= result.size
388
+
389
+ next unless result[check_pos] == line[1..-1]
390
+
391
+ src_line = check_pos
392
+ dst_line = check_pos
393
+ found = true
394
+ break
395
+ end
396
+
397
+ return nil unless found # Line to remove doesn't match, cannot apply hunk
398
+ end
399
+
400
+ # Remove the line
401
+ result.delete_at(src_line)
402
+ elsif line.start_with?("+") # Addition
403
+ # Insert the new line
404
+ result.insert(dst_line, line[1..-1])
405
+ dst_line += 1
406
+ end
407
+ end
408
+
409
+ result
410
+ end
411
+ end
412
+ end
413
+ end
data/lib/roast/tools.rb CHANGED
@@ -7,6 +7,7 @@ require "roast/tools/grep"
7
7
  require "roast/tools/read_file"
8
8
  require "roast/tools/search_file"
9
9
  require "roast/tools/write_file"
10
+ require "roast/tools/update_files"
10
11
  require "roast/tools/cmd"
11
12
  require "roast/tools/coding_agent"
12
13
 
data/lib/roast/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Roast
4
- VERSION = "0.1.6"
4
+ VERSION = "0.1.7"
5
5
  end
data/roast.gemspec CHANGED
@@ -37,6 +37,7 @@ Gem::Specification.new do |spec|
37
37
  spec.require_paths = ["lib"]
38
38
 
39
39
  spec.add_dependency("activesupport", "~> 8.0")
40
+ spec.add_dependency("diff-lcs", "~> 1.5")
40
41
  spec.add_dependency("faraday-retry")
41
42
  spec.add_dependency("json-schema")
42
43
  spec.add_dependency("raix", "~> 0.8.4")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: roast-ai
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shopify
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '8.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: diff-lcs
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.5'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.5'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: faraday-retry
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -169,6 +183,7 @@ files:
169
183
  - lib/roast/tools/grep.rb
170
184
  - lib/roast/tools/read_file.rb
171
185
  - lib/roast/tools/search_file.rb
186
+ - lib/roast/tools/update_files.rb
172
187
  - lib/roast/tools/write_file.rb
173
188
  - lib/roast/version.rb
174
189
  - lib/roast/workflow.rb