ace-bundle 0.43.9 → 0.44.1
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/.ace-defaults/bundle/presets/project-base.md +0 -7
- data/.ace-defaults/bundle/presets/project.md +0 -8
- data/CHANGELOG.md +18 -0
- data/lib/ace/bundle/models/bundle_data.rb +4 -2
- data/lib/ace/bundle/molecules/preset_manager.rb +12 -1
- data/lib/ace/bundle/molecules/section_formatter.rb +15 -7
- data/lib/ace/bundle/organisms/bundle_loader.rb +289 -38
- data/lib/ace/bundle/organisms/pr_bundle_loader.rb +5 -4
- data/lib/ace/bundle/version.rb +1 -1
- metadata +20 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5997401e24d93891ad8ecb468d6208ccd66e94740e5e956d5c2c3015a76200e7
|
|
4
|
+
data.tar.gz: 04c1948c18534e26ed77648fd3842942ee8f811c4db20ec6a340ca202ef48377
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd8103d7c27e066f6f235d5993983d7ec8134f2c8998bedb22c8117d6585ee7e70a712f0eaaf9b0b9d96d195529c053454c3921873b40b32debc5ca26e268ef8
|
|
7
|
+
data.tar.gz: a5cc4d1c3525d2c127ecd7b497421cf9aaf98c8741e3c9a8f70f54f8492ce1c7177f2f8294ea477efbf472aae24a88d4ed9cc19ff31494e10ccb4b8a2cf372e9
|
|
@@ -20,13 +20,6 @@ bundle:
|
|
|
20
20
|
TODO: Describe the top-level folders and where core logic lives.
|
|
21
21
|
Move this to docs/blueprint.md once project docs exist.
|
|
22
22
|
|
|
23
|
-
quick_status:
|
|
24
|
-
title: Quick Status
|
|
25
|
-
compressor_mode: exact
|
|
26
|
-
commands:
|
|
27
|
-
- pwd
|
|
28
|
-
- date
|
|
29
|
-
|
|
30
23
|
starter_guidance:
|
|
31
24
|
title: Starter Guidance
|
|
32
25
|
content: |
|
|
@@ -20,14 +20,6 @@ bundle:
|
|
|
20
20
|
TODO: Add key architecture notes, major components, and decision records.
|
|
21
21
|
You can later split this into docs/architecture.md, docs/decisions.md, and docs/blueprint.md.
|
|
22
22
|
|
|
23
|
-
project_status:
|
|
24
|
-
title: Project Status
|
|
25
|
-
compressor_mode: exact
|
|
26
|
-
commands:
|
|
27
|
-
- pwd
|
|
28
|
-
- date
|
|
29
|
-
- ls -1
|
|
30
|
-
|
|
31
23
|
onboarding_notes:
|
|
32
24
|
title: Onboarding Notes
|
|
33
25
|
content: |
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,24 @@ The format is based on [Keep a Changelog][1], and this project adheres to [Seman
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.44.1] - 2026-09-27
|
|
10
|
+
|
|
11
|
+
### Technical
|
|
12
|
+
- Enforce new gem dependency floors for review safety in the gemspec.
|
|
13
|
+
|
|
14
|
+
## [0.44.0] - 2026-09-24
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Make review-safe bundles reject command and external diff sources before execution, preserve source snapshots for coverage checks, and fail when required context is incomplete.
|
|
19
|
+
- Preserve file and diff bytes in rendered context, including trailing whitespace and final newlines.
|
|
20
|
+
- Include top-level files with base and embedded templates in normal bundle output.
|
|
21
|
+
- Remove volatile status commands from shipped starter presets.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Preserve base-file provenance in rendered bundle metadata so PR review can verify it against the reviewed commit.
|
|
26
|
+
- Include file inputs in merged bundle source metadata.
|
|
9
27
|
|
|
10
28
|
## [0.43.9] - 2026-09-02
|
|
11
29
|
|
|
@@ -5,7 +5,7 @@ module Ace
|
|
|
5
5
|
module Models
|
|
6
6
|
# Data model for bundle information
|
|
7
7
|
class BundleData
|
|
8
|
-
attr_accessor :preset_name, :files, :metadata, :content, :commands, :sections
|
|
8
|
+
attr_accessor :preset_name, :files, :metadata, :content, :commands, :sections, :source_files
|
|
9
9
|
|
|
10
10
|
def initialize(preset_name: nil, files: nil, metadata: nil, content: "", commands: nil, sections: nil)
|
|
11
11
|
@preset_name = preset_name
|
|
@@ -14,6 +14,7 @@ module Ace
|
|
|
14
14
|
@content = content
|
|
15
15
|
@commands = commands || []
|
|
16
16
|
@sections = sections || {}
|
|
17
|
+
@source_files = []
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def to_h
|
|
@@ -23,7 +24,8 @@ module Ace
|
|
|
23
24
|
metadata: metadata,
|
|
24
25
|
content: content,
|
|
25
26
|
commands: commands,
|
|
26
|
-
sections: sections
|
|
27
|
+
sections: sections,
|
|
28
|
+
source_files: source_files
|
|
27
29
|
}
|
|
28
30
|
end
|
|
29
31
|
|
|
@@ -10,12 +10,14 @@ module Ace
|
|
|
10
10
|
module Molecules
|
|
11
11
|
# Manages context presets from markdown files in .ace/bundle/presets/
|
|
12
12
|
class PresetManager
|
|
13
|
-
attr_reader :presets
|
|
13
|
+
attr_reader :presets, :used_source_files
|
|
14
14
|
|
|
15
15
|
def initialize
|
|
16
16
|
@section_validator = Atoms::SectionValidator.new
|
|
17
|
+
@source_snapshots = {}
|
|
17
18
|
@presets = load_presets
|
|
18
19
|
@preset_cache = {} # Cache for composed presets during single execution
|
|
20
|
+
@used_source_files = []
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def list_presets
|
|
@@ -24,9 +26,16 @@ module Ace
|
|
|
24
26
|
|
|
25
27
|
def get_preset(name)
|
|
26
28
|
preset = @presets[name.to_s]
|
|
29
|
+
@used_source_files << preset[:source_file] if preset && preset[:source_file]
|
|
27
30
|
preset&.dup
|
|
28
31
|
end
|
|
29
32
|
|
|
33
|
+
def used_source_snapshots
|
|
34
|
+
@used_source_files.uniq.map do |path|
|
|
35
|
+
{path: path, content: @source_snapshots.fetch(path)}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
30
39
|
def preset_exists?(name)
|
|
31
40
|
@presets.key?(name.to_s)
|
|
32
41
|
end
|
|
@@ -264,6 +273,8 @@ module Ace
|
|
|
264
273
|
|
|
265
274
|
return nil unless frontmatter
|
|
266
275
|
|
|
276
|
+
@source_snapshots[file] = content
|
|
277
|
+
|
|
267
278
|
# Use 'bundle' key for preset configuration
|
|
268
279
|
bundle_config = frontmatter["bundle"] || {}
|
|
269
280
|
params = bundle_config["params"] || {}
|
|
@@ -123,7 +123,9 @@ module Ace
|
|
|
123
123
|
files.each do |file_info|
|
|
124
124
|
language = detect_language(file_info[:path])
|
|
125
125
|
output << " <file path=\"#{file_info[:path]}\" language=\"#{language}\">"
|
|
126
|
-
|
|
126
|
+
content = format_file_content(file_info[:content])
|
|
127
|
+
# A review subject must remain byte-exact for the provenance check.
|
|
128
|
+
output << ((section_data[:verbatim_files] || section_data["verbatim_files"]) ? content : format_xml_payload(content))
|
|
127
129
|
output << " </file>"
|
|
128
130
|
end
|
|
129
131
|
|
|
@@ -137,7 +139,7 @@ module Ace
|
|
|
137
139
|
commands = section_data[:_processed_commands] || section_data["_processed_commands"] || []
|
|
138
140
|
commands.each do |command_data|
|
|
139
141
|
output << " <output command=\"#{command_data[:command]}\">"
|
|
140
|
-
output << format_command_output(command_data[:output])
|
|
142
|
+
output << format_xml_payload(format_command_output(command_data[:output]))
|
|
141
143
|
output << " </output>"
|
|
142
144
|
end
|
|
143
145
|
|
|
@@ -152,6 +154,8 @@ module Ace
|
|
|
152
154
|
diffs.each do |diff_data|
|
|
153
155
|
command = diff_command_for(diff_data)
|
|
154
156
|
output << " <output command=\"#{command}\">"
|
|
157
|
+
# Review diff provenance requires these bytes to remain contiguous.
|
|
158
|
+
# The XML-like tags are presentation markers, not a parsed XML envelope.
|
|
155
159
|
output << format_diff_output(diff_data[:output])
|
|
156
160
|
output << " </output>"
|
|
157
161
|
end
|
|
@@ -175,7 +179,7 @@ module Ace
|
|
|
175
179
|
# Formats inline content section
|
|
176
180
|
def format_content_section(section_data)
|
|
177
181
|
content = section_data[:_processed_content] || section_data["_processed_content"] || ""
|
|
178
|
-
format_inline_content(content)
|
|
182
|
+
format_xml_payload(format_inline_content(content))
|
|
179
183
|
end
|
|
180
184
|
|
|
181
185
|
# Formats full bundle data with sections in markdown format
|
|
@@ -472,8 +476,13 @@ module Ace
|
|
|
472
476
|
def format_file_content(content)
|
|
473
477
|
return "" if content.nil? || content.empty?
|
|
474
478
|
|
|
475
|
-
#
|
|
476
|
-
|
|
479
|
+
# Keep source bytes intact. Indentation/trim here changes diff context,
|
|
480
|
+
# including significant trailing whitespace and final newlines.
|
|
481
|
+
content
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def format_xml_payload(content)
|
|
485
|
+
"<![CDATA[#{content.to_s.gsub("]]>", "]]]]><![CDATA[>")}]]>"
|
|
477
486
|
end
|
|
478
487
|
|
|
479
488
|
def format_command_output(output)
|
|
@@ -486,8 +495,7 @@ module Ace
|
|
|
486
495
|
def format_diff_output(output)
|
|
487
496
|
return "" if output.nil? || output.empty?
|
|
488
497
|
|
|
489
|
-
|
|
490
|
-
output.lines.map { |line| " #{line}" }.join.rstrip
|
|
498
|
+
output
|
|
491
499
|
end
|
|
492
500
|
|
|
493
501
|
def format_inline_content(content)
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require "pathname"
|
|
4
4
|
require "date"
|
|
5
|
+
require "json"
|
|
6
|
+
require "yaml"
|
|
5
7
|
require "ace/core"
|
|
6
8
|
require_relative "../molecules/bundle_merger"
|
|
7
9
|
require "ace/core/molecules/file_aggregator"
|
|
@@ -27,13 +29,18 @@ module Ace
|
|
|
27
29
|
class BundleLoader
|
|
28
30
|
def initialize(options = {})
|
|
29
31
|
@options = options
|
|
32
|
+
@review_safe_mode = options[:allow_commands] == false || options["allow_commands"] == false
|
|
33
|
+
@review_allowed_root = options[:allowed_root] || options["allowed_root"] || project_root if @review_safe_mode
|
|
34
|
+
@allowed_protocol_files = []
|
|
30
35
|
@template_dir = nil
|
|
31
36
|
@preset_manager = Molecules::PresetManager.new
|
|
32
37
|
@section_processor = Molecules::SectionProcessor.new
|
|
33
38
|
@merger = Molecules::BundleMerger.new
|
|
34
39
|
@file_aggregator = Ace::Core::Molecules::FileAggregator.new(
|
|
35
40
|
max_size: options[:max_size],
|
|
36
|
-
base_dir: options[:base_dir] || project_root
|
|
41
|
+
base_dir: options[:base_dir] || project_root,
|
|
42
|
+
allowed_root: safe_allowed_root,
|
|
43
|
+
allowed_paths: @allowed_protocol_files
|
|
37
44
|
)
|
|
38
45
|
@command_executor = Ace::Core::Atoms::CommandExecutor
|
|
39
46
|
@output_formatter = Ace::Core::Molecules::OutputFormatter.new(
|
|
@@ -43,6 +50,7 @@ module Ace
|
|
|
43
50
|
|
|
44
51
|
def load_preset(preset_name)
|
|
45
52
|
# Use composition-aware loading
|
|
53
|
+
preset_sources_start = @preset_manager.used_source_files.length
|
|
46
54
|
preset = @preset_manager.load_preset_with_composition(preset_name)
|
|
47
55
|
|
|
48
56
|
# Handle errors from composition loading
|
|
@@ -55,7 +63,7 @@ module Ace
|
|
|
55
63
|
|
|
56
64
|
# Merge params into options for processing
|
|
57
65
|
params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
|
|
58
|
-
merged_options =
|
|
66
|
+
merged_options = merge_params(params)
|
|
59
67
|
|
|
60
68
|
# Process the preset bundle configuration
|
|
61
69
|
begin
|
|
@@ -78,26 +86,16 @@ module Ace
|
|
|
78
86
|
bundle.metadata[:composed_from] = preset[:composed_from]
|
|
79
87
|
end
|
|
80
88
|
|
|
81
|
-
|
|
82
|
-
# Check for explicit format request in preset or params
|
|
83
|
-
explicit_format = preset[:format] || params["format"] || params[:format] || merged_options[:format]
|
|
89
|
+
format_preset_bundle(bundle, preset, params, merged_options)
|
|
84
90
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
explicit_format
|
|
88
|
-
elsif preset.dig(:context, "embed_document_source")
|
|
89
|
-
# Default to markdown-xml format when embed_document_source is true and no explicit format requested
|
|
90
|
-
"markdown-xml"
|
|
91
|
-
else
|
|
92
|
-
# Fallback to markdown
|
|
93
|
-
"markdown"
|
|
94
|
-
end
|
|
95
|
-
format_bundle(bundle, format)
|
|
91
|
+
record_review_preset_sources!(bundle, since: preset_sources_start)
|
|
92
|
+
include_review_section_sources!(bundle)
|
|
96
93
|
|
|
97
94
|
bundle
|
|
98
95
|
end
|
|
99
96
|
|
|
100
97
|
def load_file(path)
|
|
98
|
+
assert_allowed_file!(path)
|
|
101
99
|
# Check if it's a template file
|
|
102
100
|
content = begin
|
|
103
101
|
File.read(path)
|
|
@@ -126,6 +124,7 @@ module Ace
|
|
|
126
124
|
# Plain file inputs should emit readable content to stdout.
|
|
127
125
|
# Keep content as primary payload and record source metadata.
|
|
128
126
|
bundle.content = result[:content]
|
|
127
|
+
bundle.source_files = [{path: path, content: result[:content]}] if @review_safe_mode
|
|
129
128
|
bundle.metadata[:raw_content_for_auto_format] = bundle.content
|
|
130
129
|
bundle.metadata[:source] = path
|
|
131
130
|
else
|
|
@@ -143,11 +142,12 @@ module Ace
|
|
|
143
142
|
|
|
144
143
|
preset_names.each do |preset_name|
|
|
145
144
|
# Use composition-aware loading for each preset
|
|
145
|
+
preset_sources_start = @preset_manager.used_source_files.length
|
|
146
146
|
preset = @preset_manager.load_preset_with_composition(preset_name)
|
|
147
147
|
|
|
148
148
|
if preset[:success]
|
|
149
149
|
params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
|
|
150
|
-
merged_options =
|
|
150
|
+
merged_options = merge_params(params)
|
|
151
151
|
bundle = load_from_preset_config(preset, merged_options)
|
|
152
152
|
bundle.metadata[:preset_name] = preset_name
|
|
153
153
|
bundle.metadata[:output] = preset[:output] # Store preset's output mode
|
|
@@ -158,6 +158,9 @@ module Ace
|
|
|
158
158
|
bundle.metadata[:composed_from] = preset[:composed_from]
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
+
record_review_preset_sources!(bundle, since: preset_sources_start)
|
|
162
|
+
format_preset_bundle(bundle, preset, params, merged_options) if @review_safe_mode
|
|
163
|
+
|
|
161
164
|
bundles << bundle
|
|
162
165
|
else
|
|
163
166
|
# Log warning but continue with other presets
|
|
@@ -180,6 +183,7 @@ module Ace
|
|
|
180
183
|
# Merge all bundles
|
|
181
184
|
merged = merge_bundles(bundles)
|
|
182
185
|
merged.metadata[:warnings] = warnings if warnings.any?
|
|
186
|
+
merged.metadata[:errors] = Array(merged.metadata[:errors]) + warnings if @review_safe_mode && warnings.any?
|
|
183
187
|
|
|
184
188
|
merged
|
|
185
189
|
end
|
|
@@ -309,11 +313,12 @@ module Ace
|
|
|
309
313
|
# Process presets
|
|
310
314
|
preset_names.each do |preset_name|
|
|
311
315
|
# Use composition-aware loading for each preset
|
|
316
|
+
preset_sources_start = @preset_manager.used_source_files.length
|
|
312
317
|
preset = @preset_manager.load_preset_with_composition(preset_name)
|
|
313
318
|
|
|
314
319
|
if preset[:success]
|
|
315
320
|
params = preset.dig(:context, :params) || preset.dig(:context, "params") || {}
|
|
316
|
-
merged_options =
|
|
321
|
+
merged_options = merge_params(params)
|
|
317
322
|
bundle = load_from_preset_config(preset, merged_options)
|
|
318
323
|
bundle.metadata[:preset_name] = preset_name
|
|
319
324
|
bundle.metadata[:source_type] = "preset"
|
|
@@ -325,6 +330,9 @@ module Ace
|
|
|
325
330
|
bundle.metadata[:composed_from] = preset[:composed_from]
|
|
326
331
|
end
|
|
327
332
|
|
|
333
|
+
record_review_preset_sources!(bundle, since: preset_sources_start)
|
|
334
|
+
format_preset_bundle(bundle, preset, params, merged_options) if @review_safe_mode
|
|
335
|
+
|
|
328
336
|
bundles << bundle
|
|
329
337
|
else
|
|
330
338
|
# Log warning but continue with other inputs
|
|
@@ -358,6 +366,7 @@ module Ace
|
|
|
358
366
|
|
|
359
367
|
# Add warnings to metadata if any
|
|
360
368
|
merged_bundle.metadata[:warnings] = warnings if warnings.any?
|
|
369
|
+
merged_bundle.metadata[:errors] = Array(merged_bundle.metadata[:errors]) + warnings if @review_safe_mode && warnings.any?
|
|
361
370
|
|
|
362
371
|
merged_bundle
|
|
363
372
|
end
|
|
@@ -416,6 +425,8 @@ module Ace
|
|
|
416
425
|
end
|
|
417
426
|
|
|
418
427
|
def load_template(path)
|
|
428
|
+
assert_allowed_file!(path)
|
|
429
|
+
preset_sources_start = @preset_manager.used_source_files.length
|
|
419
430
|
# Track the template file's directory for resolving ./ relative paths
|
|
420
431
|
@template_dir = File.dirname(File.expand_path(path))
|
|
421
432
|
|
|
@@ -454,7 +465,7 @@ module Ace
|
|
|
454
465
|
# Merge params into options if present
|
|
455
466
|
params = config["params"]
|
|
456
467
|
if params.is_a?(Hash)
|
|
457
|
-
@options =
|
|
468
|
+
@options = merge_params(params)
|
|
458
469
|
end
|
|
459
470
|
|
|
460
471
|
# Handle preset/presets keys from frontmatter
|
|
@@ -515,41 +526,58 @@ module Ace
|
|
|
515
526
|
|
|
516
527
|
# Track base resolution before metadata reset (metadata gets replaced below)
|
|
517
528
|
resolved = bundle.metadata[:base_type] ? bundle.content : nil
|
|
529
|
+
base_path = bundle.metadata[:base_path]
|
|
518
530
|
base_content_resolved = resolved.to_s.strip.empty? ? nil : resolved
|
|
519
531
|
|
|
520
532
|
# Replace metadata with original frontmatter (keep it unmodified)
|
|
521
533
|
# Convert string keys to symbols for consistency
|
|
534
|
+
processing_errors = Array(bundle.metadata[:errors])
|
|
535
|
+
if @review_safe_mode
|
|
536
|
+
processing_errors << bundle.metadata[:preset_error] if bundle.metadata[:preset_error]
|
|
537
|
+
processing_errors << bundle.metadata[:base_error] if bundle.metadata[:base_error] && !processing_errors.include?(bundle.metadata[:base_error])
|
|
538
|
+
end
|
|
522
539
|
bundle.metadata = {}
|
|
523
540
|
frontmatter.each do |key, value|
|
|
524
541
|
bundle.metadata[key.to_sym] = value
|
|
525
542
|
end
|
|
543
|
+
bundle.metadata[:base_path] = base_path if base_path
|
|
544
|
+
bundle.metadata[:errors] = processing_errors if processing_errors.any?
|
|
545
|
+
record_review_preset_sources!(bundle, since: preset_sources_start)
|
|
526
546
|
# Store original YAML for output formatting
|
|
527
547
|
bundle.metadata[:frontmatter_yaml] = frontmatter_yaml if frontmatter_yaml
|
|
528
548
|
|
|
529
549
|
# If embed_document_source is true, store original document and keep embedded files separate
|
|
530
550
|
if config["embed_document_source"]
|
|
531
551
|
# base replaces the source document for embedding
|
|
532
|
-
bundle.content = base_content_resolved
|
|
552
|
+
bundle.content = if base_content_resolved
|
|
553
|
+
base_content_resolved
|
|
554
|
+
else
|
|
555
|
+
[original_content, (bundle.content if bundle.source_files.any? && bundle.has_sections?)].compact.join("\n\n")
|
|
556
|
+
end
|
|
533
557
|
|
|
534
558
|
# bundle.files already has embedded files from process_template_config
|
|
535
559
|
# Don't add source to files array - it will be output as raw content
|
|
536
560
|
|
|
537
561
|
# Format and return
|
|
538
562
|
format = config["format"] || @options[:format] || "markdown-xml"
|
|
539
|
-
|
|
563
|
+
format_bundle(bundle, format)
|
|
564
|
+
bundle.metadata[:rendered_format] = format
|
|
565
|
+
return record_review_template_source!(bundle, path, original_content)
|
|
540
566
|
end
|
|
541
567
|
|
|
542
568
|
# Format bundle before returning (same as preset loading)
|
|
543
569
|
format = config["format"] || @options[:format] || "markdown-xml"
|
|
544
570
|
format_bundle(bundle, format)
|
|
571
|
+
bundle.metadata[:rendered_format] = format
|
|
545
572
|
|
|
546
|
-
return bundle
|
|
573
|
+
return record_review_template_source!(bundle, path, original_content)
|
|
547
574
|
end
|
|
548
575
|
|
|
549
576
|
# Check if this is plain markdown with metadata-only frontmatter
|
|
550
577
|
# (e.g., workflow files with description/allowed-tools but no context config)
|
|
551
578
|
if frontmatter.any?
|
|
552
|
-
|
|
579
|
+
bundle = load_plain_markdown(original_content, frontmatter, path)
|
|
580
|
+
return record_review_template_source!(bundle, path, original_content)
|
|
553
581
|
end
|
|
554
582
|
|
|
555
583
|
# Otherwise, parse template configuration from body
|
|
@@ -558,7 +586,7 @@ module Ace
|
|
|
558
586
|
unless parse_result[:success]
|
|
559
587
|
bundle = Models::BundleData.new
|
|
560
588
|
bundle.metadata[:error] = parse_result[:error]
|
|
561
|
-
return bundle
|
|
589
|
+
return record_review_template_source!(bundle, path, original_content)
|
|
562
590
|
end
|
|
563
591
|
|
|
564
592
|
config = parse_result[:config]
|
|
@@ -572,7 +600,7 @@ module Ace
|
|
|
572
600
|
# Add frontmatter to metadata for reference
|
|
573
601
|
bundle.metadata[:frontmatter] = frontmatter if frontmatter.any?
|
|
574
602
|
|
|
575
|
-
bundle
|
|
603
|
+
record_review_template_source!(bundle, path, original_content)
|
|
576
604
|
end
|
|
577
605
|
|
|
578
606
|
def load_from_config(config)
|
|
@@ -591,6 +619,8 @@ module Ace
|
|
|
591
619
|
aggregator = Ace::Core::Molecules::FileAggregator.new(
|
|
592
620
|
max_size: @options[:max_size],
|
|
593
621
|
base_dir: @options[:base_dir] || project_root,
|
|
622
|
+
allowed_root: safe_allowed_root,
|
|
623
|
+
allowed_paths: @allowed_protocol_files,
|
|
594
624
|
exclude: config[:exclude] || []
|
|
595
625
|
)
|
|
596
626
|
|
|
@@ -650,7 +680,7 @@ module Ace
|
|
|
650
680
|
|
|
651
681
|
# If embed_document_source is true, set content to trigger XML formatting
|
|
652
682
|
if bundle_config["embed_document_source"] && preset[:body] && !preset[:body].empty?
|
|
653
|
-
bundle.content = preset[:body]
|
|
683
|
+
bundle.content = [bundle.content, preset[:body]].reject(&:empty?).join("\n\n")
|
|
654
684
|
elsif preset[:body] && !preset[:body].empty?
|
|
655
685
|
# Add preset body to metadata (old behavior for non-embedded)
|
|
656
686
|
bundle.metadata[:preset_content] = preset[:body]
|
|
@@ -837,13 +867,59 @@ module Ace
|
|
|
837
867
|
def merge_bundles(bundles)
|
|
838
868
|
return Models::BundleData.new if bundles.empty?
|
|
839
869
|
|
|
870
|
+
if @review_safe_mode
|
|
871
|
+
contents = bundles.map(&:content).reject(&:empty?)
|
|
872
|
+
preset_formats = bundles.map { |bundle| bundle.metadata[:rendered_format] }.compact.uniq
|
|
873
|
+
format = @options[:format] || @options["format"] ||
|
|
874
|
+
((preset_formats.first if preset_formats.size == 1) || "markdown-xml")
|
|
875
|
+
merged_content = case format
|
|
876
|
+
when "json"
|
|
877
|
+
JSON.pretty_generate("bundles" => contents)
|
|
878
|
+
when "yaml"
|
|
879
|
+
YAML.dump("bundles" => contents)
|
|
880
|
+
when "xml"
|
|
881
|
+
entries = contents.map { |content| "<bundle><![CDATA[#{content.gsub("]]>", "]]]]><![CDATA[>")}]]></bundle>" }
|
|
882
|
+
"<bundles>\n#{entries.join("\n")}\n</bundles>"
|
|
883
|
+
else
|
|
884
|
+
contents.join("\n\n")
|
|
885
|
+
end
|
|
886
|
+
merged_sections = if bundles.size == 1
|
|
887
|
+
bundles.first.sections
|
|
888
|
+
else
|
|
889
|
+
bundles.each_with_index.each_with_object({}) do |(bundle, index), sections|
|
|
890
|
+
bundle.sections.each { |name, section| sections["#{index + 1}:#{name}"] = section }
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
result = Models::BundleData.new(
|
|
894
|
+
content: merged_content,
|
|
895
|
+
sections: merged_sections,
|
|
896
|
+
metadata: {
|
|
897
|
+
merged: true,
|
|
898
|
+
total_bundles: bundles.size,
|
|
899
|
+
sources: bundles.map { |bundle| bundle.metadata[:preset_name] || bundle.metadata[:source_path] || bundle.metadata[:source_input] }.compact,
|
|
900
|
+
errors: bundles.flat_map do |bundle|
|
|
901
|
+
Array(bundle.metadata[:errors]) + Array(bundle.metadata[:error]) + Array(bundle.metadata[:warnings])
|
|
902
|
+
end,
|
|
903
|
+
preset_source_files: bundles.flat_map { |bundle| Array(bundle.metadata[:preset_source_files]) }.uniq,
|
|
904
|
+
preset_sources: bundles.flat_map { |bundle| Array(bundle.metadata[:preset_sources]) }.uniq
|
|
905
|
+
}
|
|
906
|
+
)
|
|
907
|
+
result.source_files = bundles.flat_map do |bundle|
|
|
908
|
+
bundle.source_files +
|
|
909
|
+
bundle.sections.values.flat_map { |section| Array(section[:_processed_files]) }
|
|
910
|
+
end.uniq { |file| [file[:path], file[:content]] }
|
|
911
|
+
# Inputs are already formatted. Preserve each complete payload while
|
|
912
|
+
# keeping the requested outer format parseable.
|
|
913
|
+
return result
|
|
914
|
+
end
|
|
915
|
+
|
|
840
916
|
# Single context with actual processed section content: preserve sections
|
|
841
917
|
# This handles presets with explicit `sections:` that have real content
|
|
842
918
|
if bundles.size == 1 && has_processed_section_content?(bundles.first)
|
|
843
919
|
result = bundles.first
|
|
844
920
|
result.metadata[:merged] = true
|
|
845
921
|
result.metadata[:total_bundles] = 1
|
|
846
|
-
result.metadata[:sources] = [result.metadata[:preset_name] || result.metadata[:source_path]].compact
|
|
922
|
+
result.metadata[:sources] = [result.metadata[:preset_name] || result.metadata[:source_path] || result.metadata[:source_input]].compact
|
|
847
923
|
return format_bundle(result, @options[:format] || "markdown-xml")
|
|
848
924
|
end
|
|
849
925
|
|
|
@@ -873,6 +949,9 @@ module Ace
|
|
|
873
949
|
result.metadata[:total_bundles] = merged[:total_bundles]
|
|
874
950
|
result.metadata[:sources] = merged[:sources]
|
|
875
951
|
result.metadata[:errors] = merged[:errors] if merged[:errors]&.any?
|
|
952
|
+
result.metadata[:preset_source_files] = bundles.flat_map { |bundle| Array(bundle.metadata[:preset_source_files]) }.uniq
|
|
953
|
+
result.metadata[:preset_sources] = bundles.flat_map { |bundle| Array(bundle.metadata[:preset_sources]) }.uniq
|
|
954
|
+
result.source_files = bundles.flat_map(&:source_files)
|
|
876
955
|
|
|
877
956
|
format_bundle(result, @options[:format] || "markdown-xml")
|
|
878
957
|
end
|
|
@@ -907,13 +986,25 @@ module Ace
|
|
|
907
986
|
# Process files
|
|
908
987
|
if config["files"] && config["files"].any?
|
|
909
988
|
# Resolve any protocol references (e.g., wfi://workflow-name)
|
|
910
|
-
resolved_files = config["files"].
|
|
911
|
-
resolve_file_reference(file_ref)
|
|
912
|
-
|
|
989
|
+
resolved_files = config["files"].filter_map do |file_ref|
|
|
990
|
+
resolved = resolve_file_reference(file_ref)
|
|
991
|
+
if !resolved && @review_safe_mode
|
|
992
|
+
data[:errors] << "Unresolved file source: #{file_ref}"
|
|
993
|
+
end
|
|
994
|
+
resolved
|
|
995
|
+
end
|
|
996
|
+
missing_sources = missing_review_sources(resolved_files, base_dir: @options[:base_dir] || project_root,
|
|
997
|
+
exclude: config["exclude"] || [])
|
|
998
|
+
missing_sources.each do |pattern|
|
|
999
|
+
data[:errors] << "Unmatched required file pattern: #{pattern}"
|
|
1000
|
+
end
|
|
1001
|
+
resolved_files -= missing_sources
|
|
913
1002
|
|
|
914
1003
|
aggregator = Ace::Core::Molecules::FileAggregator.new(
|
|
915
1004
|
max_size: config["max_size"] || @options[:max_size],
|
|
916
1005
|
base_dir: @options[:base_dir] || project_root,
|
|
1006
|
+
allowed_root: safe_allowed_root,
|
|
1007
|
+
allowed_paths: @allowed_protocol_files,
|
|
917
1008
|
exclude: config["exclude"] || []
|
|
918
1009
|
)
|
|
919
1010
|
|
|
@@ -932,9 +1023,20 @@ module Ace
|
|
|
932
1023
|
|
|
933
1024
|
# Process include patterns (similar to files)
|
|
934
1025
|
if config["include"] && config["include"].any?
|
|
1026
|
+
if @review_safe_mode
|
|
1027
|
+
config["include"].each do |pattern|
|
|
1028
|
+
if Ace::Core::Atoms::GlobExpander.expand_with_exclusions(pattern,
|
|
1029
|
+
base_dir: @options[:base_dir] || project_root,
|
|
1030
|
+
exclude: config["exclude"] || []).empty?
|
|
1031
|
+
data[:errors] << "Unmatched required include pattern: #{pattern}"
|
|
1032
|
+
end
|
|
1033
|
+
end
|
|
1034
|
+
end
|
|
935
1035
|
aggregator = Ace::Core::Molecules::FileAggregator.new(
|
|
936
1036
|
max_size: config["max_size"] || @options[:max_size],
|
|
937
1037
|
base_dir: @options[:base_dir] || project_root,
|
|
1038
|
+
allowed_root: safe_allowed_root,
|
|
1039
|
+
allowed_paths: @allowed_protocol_files,
|
|
938
1040
|
exclude: config["exclude"] || []
|
|
939
1041
|
)
|
|
940
1042
|
|
|
@@ -945,6 +1047,7 @@ module Ace
|
|
|
945
1047
|
|
|
946
1048
|
# Process commands
|
|
947
1049
|
if config["commands"] && config["commands"].any?
|
|
1050
|
+
reject_untrusted_commands!
|
|
948
1051
|
timeout = config["timeout"] || @options[:timeout] || 30
|
|
949
1052
|
config["commands"].each do |command|
|
|
950
1053
|
cmd_result = @command_executor.execute(command, timeout: timeout, cwd: project_root)
|
|
@@ -959,6 +1062,7 @@ module Ace
|
|
|
959
1062
|
|
|
960
1063
|
# Process diffs
|
|
961
1064
|
if config["diffs"] && config["diffs"].any?
|
|
1065
|
+
reject_untrusted_diff_sources!
|
|
962
1066
|
data[:diffs] ||= []
|
|
963
1067
|
diff_paths = config["paths"] || config[:paths] || []
|
|
964
1068
|
config["diffs"].each do |diff_range|
|
|
@@ -980,8 +1084,10 @@ module Ace
|
|
|
980
1084
|
|
|
981
1085
|
# Create bundle with formatted content
|
|
982
1086
|
bundle = Models::BundleData.new(metadata: data[:metadata])
|
|
1087
|
+
bundle.metadata[:errors] = data[:errors] if data[:errors].any?
|
|
983
1088
|
bundle.content = formatted_content
|
|
984
1089
|
bundle.commands = data[:commands]
|
|
1090
|
+
bundle.source_files = data[:files]
|
|
985
1091
|
|
|
986
1092
|
# Store individual files if embed_document_source is true
|
|
987
1093
|
if config["embed_document_source"]
|
|
@@ -1026,6 +1132,14 @@ module Ace
|
|
|
1026
1132
|
normalized
|
|
1027
1133
|
end
|
|
1028
1134
|
|
|
1135
|
+
def format_preset_bundle(bundle, preset, params, merged_options)
|
|
1136
|
+
explicit = preset[:format] || params["format"] || params[:format] || merged_options[:format]
|
|
1137
|
+
format = explicit || (preset.dig(:context, "embed_document_source") ? "markdown-xml" : "markdown")
|
|
1138
|
+
format_bundle(bundle, format)
|
|
1139
|
+
bundle.metadata[:rendered_format] = format
|
|
1140
|
+
bundle
|
|
1141
|
+
end
|
|
1142
|
+
|
|
1029
1143
|
def format_bundle(bundle, format)
|
|
1030
1144
|
bundle.metadata[:raw_content_for_auto_format] = bundle.content
|
|
1031
1145
|
|
|
@@ -1066,6 +1180,10 @@ module Ace
|
|
|
1066
1180
|
end
|
|
1067
1181
|
|
|
1068
1182
|
def compress_bundle_sections(bundle)
|
|
1183
|
+
# PR review validates file provenance after rendering. Never send
|
|
1184
|
+
# unvalidated review sources to a model-backed compressor here.
|
|
1185
|
+
return if @review_safe_mode
|
|
1186
|
+
|
|
1069
1187
|
# --compressor off: absolute kill switch
|
|
1070
1188
|
return if @options[:compressor]&.to_s == "off"
|
|
1071
1189
|
|
|
@@ -1097,6 +1215,8 @@ module Ace
|
|
|
1097
1215
|
# section-level compression was a no-op (no _processed_files to compress).
|
|
1098
1216
|
# This handles template bundles with command-only/diff-only sections.
|
|
1099
1217
|
def compress_rendered_bundle(bundle)
|
|
1218
|
+
return if @review_safe_mode
|
|
1219
|
+
|
|
1100
1220
|
return unless bundle.has_sections?
|
|
1101
1221
|
return if bundle.metadata[:compressed]
|
|
1102
1222
|
return if sections_have_processed_files?(bundle)
|
|
@@ -1170,9 +1290,15 @@ module Ace
|
|
|
1170
1290
|
def resolve_protocol(protocol_ref)
|
|
1171
1291
|
require "ace/support/nav"
|
|
1172
1292
|
engine = Ace::Support::Nav::Organisms::NavigationEngine.new
|
|
1293
|
+
if @review_safe_mode && engine.cmd_protocol?(protocol_ref.split("://", 2).first)
|
|
1294
|
+
raise Ace::Bundle::Error, "Command protocol sources are forbidden in review prompts"
|
|
1295
|
+
end
|
|
1173
1296
|
path = engine.resolve(protocol_ref)
|
|
1174
1297
|
|
|
1175
|
-
|
|
1298
|
+
if path && File.exist?(path)
|
|
1299
|
+
authorize_protocol_path!(protocol_ref, path)
|
|
1300
|
+
return path
|
|
1301
|
+
end
|
|
1176
1302
|
|
|
1177
1303
|
# Fallback: handle cmd-type protocols (e.g., task://) by capturing command output
|
|
1178
1304
|
protocol = protocol_ref.split("://", 2).first
|
|
@@ -1230,13 +1356,27 @@ module Ace
|
|
|
1230
1356
|
return unless files.any?
|
|
1231
1357
|
|
|
1232
1358
|
# Resolve any protocol references (e.g., wfi://workflow-name)
|
|
1233
|
-
resolved_files = files.
|
|
1234
|
-
resolve_file_reference(file_ref)
|
|
1235
|
-
|
|
1359
|
+
resolved_files = files.filter_map do |file_ref|
|
|
1360
|
+
resolved = resolve_file_reference(file_ref)
|
|
1361
|
+
if !resolved && @review_safe_mode
|
|
1362
|
+
bundle.metadata[:errors] ||= []
|
|
1363
|
+
bundle.metadata[:errors] << "Section '#{section_name}': unresolved file source: #{file_ref}"
|
|
1364
|
+
end
|
|
1365
|
+
resolved
|
|
1366
|
+
end
|
|
1367
|
+
missing_sources = missing_review_sources(resolved_files, base_dir: options[:base_dir] || project_root,
|
|
1368
|
+
exclude: section_data[:exclude] || section_data["exclude"] || [])
|
|
1369
|
+
missing_sources.each do |pattern|
|
|
1370
|
+
bundle.metadata[:errors] ||= []
|
|
1371
|
+
bundle.metadata[:errors] << "Section '#{section_name}': unmatched required file pattern: #{pattern}"
|
|
1372
|
+
end
|
|
1373
|
+
resolved_files -= missing_sources
|
|
1236
1374
|
|
|
1237
1375
|
aggregator = Ace::Core::Molecules::FileAggregator.new(
|
|
1238
1376
|
max_size: options[:max_size] || options["max_size"],
|
|
1239
1377
|
base_dir: options[:base_dir] || project_root,
|
|
1378
|
+
allowed_root: safe_allowed_root,
|
|
1379
|
+
allowed_paths: @allowed_protocol_files,
|
|
1240
1380
|
exclude: section_data[:exclude] || section_data["exclude"] || []
|
|
1241
1381
|
)
|
|
1242
1382
|
|
|
@@ -1272,6 +1412,8 @@ module Ace
|
|
|
1272
1412
|
commands = section_data[:commands] || section_data["commands"] || []
|
|
1273
1413
|
return unless commands.any?
|
|
1274
1414
|
|
|
1415
|
+
reject_untrusted_commands!
|
|
1416
|
+
|
|
1275
1417
|
timeout = options[:timeout] || options["timeout"] || 30
|
|
1276
1418
|
processed_commands = []
|
|
1277
1419
|
|
|
@@ -1298,6 +1440,7 @@ module Ace
|
|
|
1298
1440
|
def process_pr_config(bundle, bundle_config, options)
|
|
1299
1441
|
pr_refs = bundle_config["pr"] || bundle_config[:pr]
|
|
1300
1442
|
return false unless pr_refs
|
|
1443
|
+
reject_untrusted_diff_sources!
|
|
1301
1444
|
|
|
1302
1445
|
PrBundleLoader.new(options).process(bundle, pr_refs)
|
|
1303
1446
|
end
|
|
@@ -1306,6 +1449,7 @@ module Ace
|
|
|
1306
1449
|
def process_diffs_section(bundle, section_name, section_data, options)
|
|
1307
1450
|
ranges = section_data[:ranges] || section_data["ranges"] || []
|
|
1308
1451
|
return unless ranges.any?
|
|
1452
|
+
reject_untrusted_diff_sources!
|
|
1309
1453
|
|
|
1310
1454
|
processed_diffs = []
|
|
1311
1455
|
diff_paths = section_data[:paths] || section_data["paths"] || []
|
|
@@ -1344,11 +1488,15 @@ module Ace
|
|
|
1344
1488
|
base_ref = bundle_config["base"] || bundle_config[:base]
|
|
1345
1489
|
return unless base_ref && !base_ref.to_s.strip.empty?
|
|
1346
1490
|
|
|
1491
|
+
files_in_content = bundle.source_files.any? &&
|
|
1492
|
+
(!bundle_config["embed_document_source"] || @section_processor.has_sections?({"bundle" => bundle_config}))
|
|
1493
|
+
|
|
1347
1494
|
# Check if base_ref looks like a file reference (has protocol, slashes, or is a known path pattern)
|
|
1348
1495
|
# This heuristic helps prioritize file resolution for extension-less files
|
|
1349
1496
|
has_protocol = base_ref.match?(/^[\w-]+:\/\//)
|
|
1350
1497
|
has_path_separators = base_ref.match?(/[\/\\]/)
|
|
1351
|
-
looks_like_file_ref = has_protocol || has_path_separators
|
|
1498
|
+
looks_like_file_ref = has_protocol || has_path_separators ||
|
|
1499
|
+
(@review_safe_mode && base_ref.match?(/\.(?:md|markdown|txt|json|ya?ml)\z/i))
|
|
1352
1500
|
|
|
1353
1501
|
# Try to resolve as file reference first (handles extension-less files like README, CONTEXT)
|
|
1354
1502
|
resolved_path = resolve_file_reference(base_ref)
|
|
@@ -1356,16 +1504,19 @@ module Ace
|
|
|
1356
1504
|
# Check if we successfully resolved to an existing file
|
|
1357
1505
|
if resolved_path && File.exist?(resolved_path)
|
|
1358
1506
|
# Load base content from file
|
|
1359
|
-
|
|
1507
|
+
assert_allowed_file!(resolved_path)
|
|
1508
|
+
base_source = File.read(resolved_path)
|
|
1509
|
+
base_content = base_source.strip
|
|
1360
1510
|
if base_content.empty?
|
|
1361
1511
|
warn "Warning: Base file is empty: #{resolved_path}" if options[:debug]
|
|
1362
1512
|
end
|
|
1363
1513
|
|
|
1364
1514
|
# Store base content as primary content
|
|
1365
|
-
bundle.content = base_content
|
|
1515
|
+
bundle.content = [base_content, (bundle.content if files_in_content)].compact.join("\n\n")
|
|
1366
1516
|
bundle.metadata[:base_path] = resolved_path
|
|
1367
1517
|
bundle.metadata[:base_ref] = base_ref
|
|
1368
1518
|
bundle.metadata[:base_type] = "file"
|
|
1519
|
+
bundle.source_files << {path: resolved_path, content: base_source}
|
|
1369
1520
|
elsif looks_like_file_ref
|
|
1370
1521
|
# It looks like a file reference but resolution failed - set error
|
|
1371
1522
|
if !resolved_path
|
|
@@ -1375,11 +1526,15 @@ module Ace
|
|
|
1375
1526
|
bundle.metadata[:base_error] = "Base file not found: #{resolved_path}"
|
|
1376
1527
|
warn "Warning: Base file not found: #{resolved_path}" if options[:debug]
|
|
1377
1528
|
end
|
|
1529
|
+
if @review_safe_mode
|
|
1530
|
+
bundle.metadata[:errors] ||= []
|
|
1531
|
+
bundle.metadata[:errors] << bundle.metadata[:base_error]
|
|
1532
|
+
end
|
|
1378
1533
|
else
|
|
1379
1534
|
# Simple string without path indicators - treat as inline content
|
|
1380
1535
|
# This allows direct definition of base context without requiring separate files
|
|
1381
1536
|
# Example: base: "System instructions for the task"
|
|
1382
|
-
bundle.content = base_ref.to_s.strip
|
|
1537
|
+
bundle.content = [base_ref.to_s.strip, (bundle.content if files_in_content)].compact.join("\n\n")
|
|
1383
1538
|
bundle.metadata[:base_type] = "inline"
|
|
1384
1539
|
bundle.metadata[:base_ref] = base_ref
|
|
1385
1540
|
end
|
|
@@ -1481,6 +1636,102 @@ module Ace
|
|
|
1481
1636
|
Atoms::TypoDetector.detect_suspicious_keys(frontmatter, path)
|
|
1482
1637
|
end
|
|
1483
1638
|
|
|
1639
|
+
def reject_untrusted_commands!
|
|
1640
|
+
return unless @review_safe_mode
|
|
1641
|
+
|
|
1642
|
+
raise Ace::Bundle::Error, "Command sources are forbidden in review prompts"
|
|
1643
|
+
end
|
|
1644
|
+
|
|
1645
|
+
def reject_untrusted_diff_sources!
|
|
1646
|
+
return unless @review_safe_mode
|
|
1647
|
+
|
|
1648
|
+
raise Ace::Bundle::Error, "Secondary PR and Git diff sources are forbidden in review prompts"
|
|
1649
|
+
end
|
|
1650
|
+
|
|
1651
|
+
def merge_params(params)
|
|
1652
|
+
if @review_safe_mode
|
|
1653
|
+
restricted = %w[allow_commands allowed_root allowed_paths base_dir]
|
|
1654
|
+
supplied = params.keys.map(&:to_s) & restricted
|
|
1655
|
+
unless supplied.empty?
|
|
1656
|
+
raise Ace::Bundle::Error, "Review template cannot override safety options: #{supplied.join(", ")}"
|
|
1657
|
+
end
|
|
1658
|
+
end
|
|
1659
|
+
@options.merge(params)
|
|
1660
|
+
end
|
|
1661
|
+
|
|
1662
|
+
def missing_review_sources(files, base_dir:, exclude:)
|
|
1663
|
+
return [] unless @review_safe_mode
|
|
1664
|
+
|
|
1665
|
+
files.select do |pattern|
|
|
1666
|
+
Ace::Core::Atoms::GlobExpander.expand_with_exclusions(pattern,
|
|
1667
|
+
base_dir: base_dir, exclude: exclude).empty?
|
|
1668
|
+
end
|
|
1669
|
+
end
|
|
1670
|
+
|
|
1671
|
+
def record_review_preset_sources!(bundle, since:)
|
|
1672
|
+
return unless @review_safe_mode
|
|
1673
|
+
|
|
1674
|
+
paths = @preset_manager.used_source_files.drop(since).uniq
|
|
1675
|
+
paths.each { |path| assert_allowed_file!(path) }
|
|
1676
|
+
bundle.metadata[:preset_source_files] = paths if paths.any?
|
|
1677
|
+
if paths.any?
|
|
1678
|
+
bundle.metadata[:preset_sources] = @preset_manager.used_source_snapshots.select { |source| paths.include?(source[:path]) }
|
|
1679
|
+
end
|
|
1680
|
+
end
|
|
1681
|
+
|
|
1682
|
+
def record_review_template_source!(bundle, path, content)
|
|
1683
|
+
return bundle unless @review_safe_mode
|
|
1684
|
+
|
|
1685
|
+
include_review_section_sources!(bundle)
|
|
1686
|
+
bundle.source_files << {path: path, content: content}
|
|
1687
|
+
bundle.source_files.uniq! { |file| [file[:path], file[:content]] }
|
|
1688
|
+
bundle
|
|
1689
|
+
end
|
|
1690
|
+
|
|
1691
|
+
def include_review_section_sources!(bundle)
|
|
1692
|
+
return unless @review_safe_mode
|
|
1693
|
+
|
|
1694
|
+
bundle.source_files += bundle.sections.values.flat_map { |section| Array(section[:_processed_files]) }
|
|
1695
|
+
bundle.source_files.uniq! { |file| [file[:path], file[:content]] }
|
|
1696
|
+
end
|
|
1697
|
+
|
|
1698
|
+
def safe_allowed_root
|
|
1699
|
+
@review_allowed_root if @review_safe_mode
|
|
1700
|
+
end
|
|
1701
|
+
|
|
1702
|
+
def assert_allowed_file!(path)
|
|
1703
|
+
return unless @review_safe_mode
|
|
1704
|
+
return unless File.exist?(path)
|
|
1705
|
+
|
|
1706
|
+
root = File.realpath(safe_allowed_root)
|
|
1707
|
+
actual = File.realpath(path)
|
|
1708
|
+
return if actual == root || actual.start_with?("#{root}#{File::SEPARATOR}")
|
|
1709
|
+
return if @allowed_protocol_files.include?(actual)
|
|
1710
|
+
|
|
1711
|
+
raise Ace::Bundle::Error, "File source outside allowed project root: #{path}"
|
|
1712
|
+
end
|
|
1713
|
+
|
|
1714
|
+
def authorize_protocol_path!(reference, path)
|
|
1715
|
+
return unless @review_safe_mode
|
|
1716
|
+
|
|
1717
|
+
actual = File.realpath(path)
|
|
1718
|
+
root = File.realpath(safe_allowed_root)
|
|
1719
|
+
return if actual == root || actual.start_with?("#{root}#{File::SEPARATOR}")
|
|
1720
|
+
|
|
1721
|
+
unless reference.start_with?("prompt://")
|
|
1722
|
+
raise Ace::Bundle::Error, "Protocol file source outside allowed project root: #{reference}"
|
|
1723
|
+
end
|
|
1724
|
+
spec = Gem.loaded_specs["ace-review"] || Gem::Specification.find_by_name("ace-review")
|
|
1725
|
+
package_root = File.realpath(spec.full_gem_path)
|
|
1726
|
+
unless actual.start_with?("#{package_root}#{File::SEPARATOR}")
|
|
1727
|
+
raise Ace::Bundle::Error, "Prompt source is not owned by the installed ace-review package: #{reference}"
|
|
1728
|
+
end
|
|
1729
|
+
|
|
1730
|
+
@allowed_protocol_files << actual unless @allowed_protocol_files.include?(actual)
|
|
1731
|
+
rescue Gem::LoadError
|
|
1732
|
+
raise Ace::Bundle::Error, "Cannot validate installed ace-review prompt source: #{reference}"
|
|
1733
|
+
end
|
|
1734
|
+
|
|
1484
1735
|
# Delegate to Atoms::TypoDetector for architectural consistency
|
|
1485
1736
|
# @deprecated Use Atoms::TypoDetector.typo_distance directly
|
|
1486
1737
|
def typo_distance(str1, str2)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "ace/git"
|
|
4
|
+
require "ace/git/github"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
6
7
|
module Bundle
|
|
@@ -9,7 +10,7 @@ module Ace
|
|
|
9
10
|
#
|
|
10
11
|
# Responsible for:
|
|
11
12
|
# - Normalizing PR references (string, array, hash formats)
|
|
12
|
-
# - Fetching diffs via Ace::Git::
|
|
13
|
+
# - Fetching diffs via Ace::Git::Github::PrFetcher
|
|
13
14
|
# - Integrating results into bundle sections
|
|
14
15
|
# - Error handling and surfacing
|
|
15
16
|
#
|
|
@@ -82,7 +83,7 @@ module Ace
|
|
|
82
83
|
# @param pr_ref [String] Single PR reference
|
|
83
84
|
# @return [Hash, nil] Diff result or nil on skip
|
|
84
85
|
def fetch_single_diff(bundle, pr_ref)
|
|
85
|
-
result = Ace::Git::
|
|
86
|
+
result = Ace::Git::Github::PrFetcher.fetch_diff(pr_ref, timeout: @timeout)
|
|
86
87
|
|
|
87
88
|
if result[:success]
|
|
88
89
|
{
|
|
@@ -96,8 +97,8 @@ module Ace
|
|
|
96
97
|
{range: pr_range_identifier(pr_ref), output: "Error: #{result[:error]}", success: false, error: result[:error], source: :pr}
|
|
97
98
|
end
|
|
98
99
|
rescue Ace::Git::Error => e
|
|
99
|
-
# Catches all ace-git errors: GitError,
|
|
100
|
-
#
|
|
100
|
+
# Catches all ace-git errors: GitError, ProviderCliMissingError, ProviderAuthenticationError,
|
|
101
|
+
# ProviderObjectNotFoundError, TimeoutError (all inherit from Ace::Git::Error)
|
|
101
102
|
record_error(bundle, "PR fetch failed for '#{pr_ref}': #{e.message}")
|
|
102
103
|
{range: pr_range_identifier(pr_ref), output: "Error: #{e.message}", success: false, error: e.message, source: :pr}
|
|
103
104
|
rescue ArgumentError => e
|
data/lib/ace/bundle/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ace-bundle
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.44.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Michal Czyz
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-09-
|
|
10
|
+
date: 2026-09-27 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: ace-support-cli
|
|
@@ -43,14 +43,14 @@ dependencies:
|
|
|
43
43
|
requirements:
|
|
44
44
|
- - "~>"
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '0.
|
|
46
|
+
version: '0.32'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - "~>"
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '0.
|
|
53
|
+
version: '0.32'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: ace-support-fs
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -71,14 +71,28 @@ dependencies:
|
|
|
71
71
|
requirements:
|
|
72
72
|
- - "~>"
|
|
73
73
|
- !ruby/object:Gem::Version
|
|
74
|
-
version: '0.
|
|
74
|
+
version: '0.24'
|
|
75
75
|
type: :runtime
|
|
76
76
|
prerelease: false
|
|
77
77
|
version_requirements: !ruby/object:Gem::Requirement
|
|
78
78
|
requirements:
|
|
79
79
|
- - "~>"
|
|
80
80
|
- !ruby/object:Gem::Version
|
|
81
|
-
version: '0.
|
|
81
|
+
version: '0.24'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: ace-git-github
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 0.1.1
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: 0.1.1
|
|
82
96
|
- !ruby/object:Gem::Dependency
|
|
83
97
|
name: ace-support-nav
|
|
84
98
|
requirement: !ruby/object:Gem::Requirement
|