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 +4 -4
- data/CHANGELOG.md +9 -0
- data/Gemfile.lock +3 -1
- data/README.md +145 -0
- data/docs/INSTRUMENTATION.md +42 -1
- data/lib/roast/tools/update_files.rb +413 -0
- data/lib/roast/tools.rb +1 -0
- data/lib/roast/version.rb +1 -1
- data/roast.gemspec +1 -0
- metadata +16 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 40bb03c596dcaa3dd49685c9b5f804ca6c6db00cf239d5e52748e6fa485f9c3b
|
4
|
+
data.tar.gz: c56399cca493aa9d925bb4d0550dc7ad0c0ed03b74b4f7894a3205ba72e66d1e
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
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):
|
data/docs/INSTRUMENTATION.md
CHANGED
@@ -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
data/lib/roast/version.rb
CHANGED
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.
|
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
|