ace-review 0.55.0 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/review/config.yml +9 -14
- data/CHANGELOG.md +15 -0
- data/handbook/workflow-instructions/review/pr.wf.md +2 -0
- data/lib/ace/review/cli/commands/review.rb +15 -0
- data/lib/ace/review/errors.rb +0 -3
- data/lib/ace/review/models/review_options.rb +10 -0
- data/lib/ace/review/molecules/delta_resolver.rb +135 -0
- data/lib/ace/review/molecules/exempt_paths.rb +61 -0
- data/lib/ace/review/molecules/preset_manager.rb +1 -0
- data/lib/ace/review/organisms/review_manager.rb +212 -0
- data/lib/ace/review/version.rb +1 -1
- data/lib/ace/review.rb +2 -1
- metadata +6 -8
- data/lib/ace/review/molecules/strategies/adaptive_strategy.rb +0 -193
- data/lib/ace/review/molecules/strategies/chunked_strategy.rb +0 -459
- data/lib/ace/review/molecules/strategies/full_strategy.rb +0 -114
- data/lib/ace/review/molecules/subject_strategy.rb +0 -96
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 92554e56ae82d2ecd841daf699eae3c77e6a97f7b01f0a6e2681804ab5c88109
|
|
4
|
+
data.tar.gz: 77450cdd27add8a7a239e4949ceec8a9b74a2e61824d0477f9e49c8a3bbeee84
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 40d4fb853eecf34a49a3aebcbee634409c82d76ca1e584c3218e5ca0fe3086a11e9a61f104e19ddae27508a51473ddf89f031eb3fab287d98f0998a7047c6353
|
|
7
|
+
data.tar.gz: f9d0635b9258e7395e56ffde24dc58977bc5dd031984a4ef05b6d779f5460f42c4090e818ce439748a9100498a8314920c8832f29d938a98dcebc31db4954899
|
|
@@ -41,21 +41,16 @@ defaults:
|
|
|
41
41
|
gh_simple_timeout: 10 # Timeout for simple gh commands (--version, auth status)
|
|
42
42
|
llm_timeout: 900 # Timeout for LLM queries in seconds (15 minutes)
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
#
|
|
44
|
+
# Review-exempt paths
|
|
45
|
+
# Globs whose changed files never require a model report. A delta round
|
|
46
|
+
# (--delta) touching only these paths is recorded as a no-op session with zero
|
|
47
|
+
# model calls; in any round, matching paths are excluded from the review
|
|
48
|
+
# subject and enumerated in the diff manifest. Patterns that match every path
|
|
49
|
+
# are refused at config load.
|
|
46
50
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
# - chunked: Split diff at file boundaries (for smaller context models)
|
|
51
|
-
#
|
|
52
|
-
# Example:
|
|
53
|
-
# subject_strategy:
|
|
54
|
-
# type: adaptive # or: full, chunked
|
|
55
|
-
# headroom: 0.15 # Reserve 15% for system prompt (default)
|
|
56
|
-
# chunking:
|
|
57
|
-
# max_tokens_per_chunk: 100000 # Max tokens per chunk
|
|
58
|
-
# include_change_summary: true # Include file summary in each chunk
|
|
51
|
+
# exempt_paths:
|
|
52
|
+
# - "CHANGELOG.md"
|
|
53
|
+
# - "**/*.golden"
|
|
59
54
|
|
|
60
55
|
# Storage configuration
|
|
61
56
|
# storage:
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.56.0] - 2026-09-27
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Delta review rounds for PRs: `--delta <head>` reviews only the diff since an explicit reference head, and bare `--delta` auto-resolves the reference from the most recent prior session of the same PR (recorded via `diff_manifest.head_sha`/`delta_reference_head` in session metadata). Prior-session findings are carried forward as evidence; a non-ancestor reference (rewritten history), missing prior sessions, and oversized deltas refuse with actionable messages instead of silently reviewing everything.
|
|
15
|
+
- Review-exempt paths: `exempt_paths` config globs. A delta round touching only exempt paths is recorded as a no-op session with zero model calls (paths and matching patterns enumerated in the report); mixed rounds review only the non-exempt part; full rounds exclude exempt paths from the subject and list them. Patterns that match every path are refused at config load.
|
|
16
|
+
|
|
17
|
+
### Removed
|
|
18
|
+
|
|
19
|
+
- The unwired subject strategy surface (`SubjectStrategy`, full/chunked/adaptive strategies, `UnknownStrategyError`, and the commented `subject_strategy` config block). Oversized diffs keep failing with the explicit budget-vs-actual refusal; delta scoping is the intended answer for large PRs (ADR-024: no documented-but-unreachable configuration).
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
- Review session usage records now reflect measured provider tokens: `LlmExecutor`/multi-model metadata record `usage` derived from provider metadata (with `usage_source` no longer pinned to "unavailable"), and no-op delta rounds record zero model calls.
|
|
24
|
+
|
|
10
25
|
## [0.55.0] - 2026-09-24
|
|
11
26
|
|
|
12
27
|
### Added
|
|
@@ -29,6 +29,8 @@ For presets with a goals brief, `--prepare-goals-brief` generates or reuses the
|
|
|
29
29
|
|
|
30
30
|
Run `ace-review --pr <number> --preset <preset> --auto-execute` for the needed scopes. Pass selected previous sessions with `--evidence-session <path>` when useful. Earlier commit SHAs are expected: prior reports provide context and dispositions, not current-code certificates. Supply a short summary of fixes and outstanding issues through the preset context when it is more useful than full reports.
|
|
31
31
|
|
|
32
|
+
Round N+1 does not need to re-review unchanged code. `--delta` scopes the round to the diff since the reference head recorded by the most recent prior session of that PR (`--delta <head>` names an explicit reference), carries its findings forward as evidence, and completes as a zero-model no-op session when the delta is empty. When the project config declares `exempt_paths` and a delta touches only those paths, the round is recorded as a review-exempt no-op; mixed deltas review only the non-exempt part. Full rounds are never converted into no-ops by exempt paths.
|
|
33
|
+
|
|
32
34
|
First-round review looks for concrete defects against the agreed requirements. Subsequent rounds check fixes, regressions, unresolved issues and the integration affected by changes. They may report newly evidenced defects, but should not redesign the solution or reopen a closed finding without new evidence.
|
|
33
35
|
|
|
34
36
|
Verify reports against code and requirements. Use `ace-review-feedback list --session <path>`, `show`, `verify` and `resolve` to preserve findings and dispositions. Distinguish defects, architectural decisions and optional improvements. A single reviewer's valid finding matters; do not discard it for lack of consensus. Severity is verified by the agent, not accepted just because the model assigned it.
|
|
@@ -34,6 +34,8 @@ module Ace
|
|
|
34
34
|
"--preset code-pr # PR code review",
|
|
35
35
|
"--preset security --auto-execute # Run and apply fixes",
|
|
36
36
|
"--pr 123 # Review by PR number",
|
|
37
|
+
"--pr 123 --delta # Delta round since the most recent prior session's head",
|
|
38
|
+
"--pr 123 --delta <head> # Delta round since an explicit reviewed head",
|
|
37
39
|
"--preset code --subject diff:HEAD~3 --subject files:docs/**/*.md",
|
|
38
40
|
"--preset security --dry-run # Preview without executing"
|
|
39
41
|
]
|
|
@@ -61,6 +63,8 @@ module Ace
|
|
|
61
63
|
option :post_comment, type: :boolean, desc: "Post review as PR comment (requires --pr)"
|
|
62
64
|
option :gh_timeout, type: :integer, desc: "Timeout for gh CLI operations in seconds (default: 30)"
|
|
63
65
|
option :evidence_session, type: :array, desc: "Include selected prior review sessions as context"
|
|
66
|
+
option :delta, type: :string, optional_value: true,
|
|
67
|
+
desc: "Delta round for --pr: review only changes since a reference head (explicit head, or bare --delta to auto-resolve from the most recent prior session for this PR)"
|
|
64
68
|
option :prepare_goals_brief, type: :boolean, desc: "Generate or reuse the shared goals brief for this PR and preset"
|
|
65
69
|
|
|
66
70
|
# Standard options
|
|
@@ -145,6 +149,7 @@ module Ace
|
|
|
145
149
|
# Handle repeatable options
|
|
146
150
|
process_subjects(options)
|
|
147
151
|
process_models(options)
|
|
152
|
+
process_delta(options)
|
|
148
153
|
|
|
149
154
|
# Set defaults
|
|
150
155
|
options[:save_session] = true unless options.key?(:save_session)
|
|
@@ -152,6 +157,16 @@ module Ace
|
|
|
152
157
|
options
|
|
153
158
|
end
|
|
154
159
|
|
|
160
|
+
# Normalize --delta: bare flag or empty value means auto-resolve the
|
|
161
|
+
# reference head from prior sessions (:auto); a value is the explicit head
|
|
162
|
+
def process_delta(options)
|
|
163
|
+
return unless options.key?(:delta)
|
|
164
|
+
|
|
165
|
+
value = options[:delta]
|
|
166
|
+
value = value.to_s.strip if value.is_a?(String)
|
|
167
|
+
options[:delta] = (value.nil? || value == "") ? :auto : value
|
|
168
|
+
end
|
|
169
|
+
|
|
155
170
|
def process_subjects(options)
|
|
156
171
|
return unless options[:subject]
|
|
157
172
|
|
data/lib/ace/review/errors.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Ace
|
|
|
12
12
|
:pr, :post_comment, :pr_metadata, :gh_timeout,
|
|
13
13
|
:pr_comments, :pr_comment_data,
|
|
14
14
|
:evidence_sessions,
|
|
15
|
+
:delta,
|
|
15
16
|
:no_feedback, :feedback_model,
|
|
16
17
|
:list_presets, :list_prompts, :help
|
|
17
18
|
|
|
@@ -60,6 +61,10 @@ module Ace
|
|
|
60
61
|
@pr_comment_data = nil # Populated during execution
|
|
61
62
|
@evidence_sessions = Array(hash[:evidence_session]).flat_map { |value| value.to_s.split("\x1F") }.reject(&:empty?)
|
|
62
63
|
|
|
64
|
+
# Delta round: nil = not requested, :auto = resolve reference from prior
|
|
65
|
+
# sessions, otherwise the explicit reference head
|
|
66
|
+
@delta = hash[:delta]
|
|
67
|
+
|
|
63
68
|
# Feedback extraction options
|
|
64
69
|
@no_feedback = hash[:no_feedback] || false
|
|
65
70
|
@feedback_model = hash[:feedback_model]
|
|
@@ -89,6 +94,11 @@ module Ace
|
|
|
89
94
|
!pr.nil? && !pr.to_s.strip.empty?
|
|
90
95
|
end
|
|
91
96
|
|
|
97
|
+
# Check if a delta round was requested (--delta, with or without a head)
|
|
98
|
+
def delta_requested?
|
|
99
|
+
!delta.nil?
|
|
100
|
+
end
|
|
101
|
+
|
|
92
102
|
# Check if comment posting should be triggered (includes dry-run preview)
|
|
93
103
|
def should_post_comment?
|
|
94
104
|
pr_review? && post_comment
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "yaml"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Review
|
|
8
|
+
module Molecules
|
|
9
|
+
# Resolves and validates the reference head for a delta review round.
|
|
10
|
+
#
|
|
11
|
+
# A delta round reviews only the diff between a reference head (an earlier
|
|
12
|
+
# reviewed commit) and the PR's current head. The reference is either given
|
|
13
|
+
# explicitly or auto-resolved from the most recent prior review session of
|
|
14
|
+
# the same PR. Every failure mode fails closed: no prior session, a
|
|
15
|
+
# non-ancestor reference (e.g. rewritten history) or missing local objects
|
|
16
|
+
# refuse the round instead of falling back to a full review.
|
|
17
|
+
module DeltaResolver
|
|
18
|
+
SHA_PATTERN = /\A[0-9a-f]{40}\z/
|
|
19
|
+
|
|
20
|
+
# Resolve the reference head and compute the delta diff.
|
|
21
|
+
#
|
|
22
|
+
# @param reference [String, Symbol, nil] explicit head, or :auto to resolve
|
|
23
|
+
# from the most recent prior session of this PR
|
|
24
|
+
# @param pr_metadata [Hash] PR metadata (requires headRefOid and url)
|
|
25
|
+
# @param project_root [String, nil] repository root for git commands
|
|
26
|
+
# @return [Hash] {success:, reference_head:, source:, session_dir:, diff:, error:}
|
|
27
|
+
def self.resolve(reference, pr_metadata, project_root: nil)
|
|
28
|
+
head = pr_metadata["headRefOid"].to_s
|
|
29
|
+
return {success: false, error: "Delta review requires the PR head SHA"} unless head.match?(SHA_PATTERN)
|
|
30
|
+
|
|
31
|
+
if reference == :auto || reference.nil?
|
|
32
|
+
session = latest_session_head(pr_metadata["url"].to_s, project_root: project_root)
|
|
33
|
+
return session unless session[:success]
|
|
34
|
+
|
|
35
|
+
reference = session[:reference_head]
|
|
36
|
+
session_dir = session[:session_dir]
|
|
37
|
+
source = :session
|
|
38
|
+
else
|
|
39
|
+
source = :explicit
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
resolved = rev_parse(reference, project_root: project_root)
|
|
43
|
+
return resolved unless resolved[:success]
|
|
44
|
+
|
|
45
|
+
reference_head = resolved[:stdout]
|
|
46
|
+
return {success: false, error: "Delta reference '#{reference}' is not a commit SHA"} unless reference_head.match?(SHA_PATTERN)
|
|
47
|
+
return {success: false, error: "Delta reference head #{reference_head} is not an ancestor of the current PR head #{head}; history was likely rewritten — re-run a full review round"} unless ancestor?(reference_head, head, project_root: project_root)
|
|
48
|
+
|
|
49
|
+
diff = run_git(["diff", "#{reference_head}..#{head}"], project_root: project_root)
|
|
50
|
+
return diff unless diff[:success]
|
|
51
|
+
|
|
52
|
+
{success: true, reference_head: reference_head, source: source,
|
|
53
|
+
session_dir: session_dir, diff: diff[:stdout]}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Find the head SHA recorded by the most recent prior session of this PR.
|
|
57
|
+
#
|
|
58
|
+
# @param pr_url [String] canonical PR URL stored in session metadata
|
|
59
|
+
# @param project_root [String, nil]
|
|
60
|
+
# @return [Hash] {success:, reference_head:, session_dir:, error:}
|
|
61
|
+
def self.latest_session_head(pr_url, project_root: nil)
|
|
62
|
+
root = project_root || Dir.pwd
|
|
63
|
+
sessions_dir = File.join(root, ".ace-local", "review", "sessions")
|
|
64
|
+
return {success: false, error: no_prior_session_message(pr_url)} unless Dir.exist?(sessions_dir)
|
|
65
|
+
|
|
66
|
+
candidates = Dir.glob(File.join(sessions_dir, "review-*"))
|
|
67
|
+
.select { |path| File.directory?(path) }
|
|
68
|
+
# Order by the session's own last write (metadata.yml), not the
|
|
69
|
+
# directory entry — directory mtimes tie when sessions are created
|
|
70
|
+
# within the same second.
|
|
71
|
+
.sort_by { |path| -session_mtime(path).to_f }
|
|
72
|
+
|
|
73
|
+
candidates.each do |dir|
|
|
74
|
+
metadata_path = File.join(dir, "metadata.yml")
|
|
75
|
+
next unless File.file?(metadata_path)
|
|
76
|
+
|
|
77
|
+
metadata = YAML.safe_load_file(metadata_path, permitted_classes: [Time, Date, Symbol])
|
|
78
|
+
next unless metadata.is_a?(Hash)
|
|
79
|
+
next unless metadata["pr_url"].to_s == pr_url
|
|
80
|
+
|
|
81
|
+
head = metadata.dig("diff_manifest", "head_sha") ||
|
|
82
|
+
metadata.dig("diff_manifest", :head_sha)
|
|
83
|
+
next unless head.to_s.match?(SHA_PATTERN)
|
|
84
|
+
|
|
85
|
+
return {success: true, reference_head: head, session_dir: dir}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{success: false, error: no_prior_session_message(pr_url)}
|
|
89
|
+
rescue Psych::Exception, Errno::ENOENT => e
|
|
90
|
+
{success: false, error: "Cannot read prior review sessions for delta resolution: #{e.message}"}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.session_mtime(session_dir)
|
|
94
|
+
metadata_path = File.join(session_dir, "metadata.yml")
|
|
95
|
+
File.file?(metadata_path) ? File.mtime(metadata_path) : File.mtime(session_dir)
|
|
96
|
+
end
|
|
97
|
+
private_class_method :session_mtime
|
|
98
|
+
|
|
99
|
+
def self.no_prior_session_message(pr_url)
|
|
100
|
+
"No prior review session records a head for #{pr_url}; " \
|
|
101
|
+
"run a full review round first or pass an explicit reference: --delta <head>"
|
|
102
|
+
end
|
|
103
|
+
private_class_method :no_prior_session_message
|
|
104
|
+
|
|
105
|
+
def self.rev_parse(reference, project_root:)
|
|
106
|
+
result = run_git(["rev-parse", "#{reference}^{commit}"], project_root: project_root)
|
|
107
|
+
return result unless result[:success]
|
|
108
|
+
|
|
109
|
+
result[:stdout] = result[:stdout].strip
|
|
110
|
+
result
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.ancestor?(reference_head, head, project_root:)
|
|
114
|
+
root = project_root || Dir.pwd
|
|
115
|
+
system("git", "merge-base", "--is-ancestor", reference_head, head, chdir: root,
|
|
116
|
+
out: File::NULL, err: File::NULL)
|
|
117
|
+
end
|
|
118
|
+
private_class_method :ancestor?
|
|
119
|
+
|
|
120
|
+
def self.run_git(args, project_root:)
|
|
121
|
+
root = project_root || Dir.pwd
|
|
122
|
+
stdout, stderr, status = Open3.capture3("git", *args, chdir: root)
|
|
123
|
+
unless status.success?
|
|
124
|
+
detail = stderr.strip
|
|
125
|
+
detail = "exit #{status.exitstatus}" if detail.empty?
|
|
126
|
+
return {success: false, error: "git #{args.first} failed for delta review: #{detail}"}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
{success: true, stdout: stdout}
|
|
130
|
+
end
|
|
131
|
+
private_class_method :run_git
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "subject_filter"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Review
|
|
7
|
+
module Molecules
|
|
8
|
+
# Review-exempt paths: globs whose deltas never require a model report.
|
|
9
|
+
#
|
|
10
|
+
# A delta round whose changed files all match the declared patterns is
|
|
11
|
+
# recorded as a no-op session without any model call; in mixed rounds the
|
|
12
|
+
# exempt paths are excluded from the subject and enumerated. Patterns are
|
|
13
|
+
# validated at config load — a pattern that matches every path is refused
|
|
14
|
+
# instead of silently exempting the world.
|
|
15
|
+
module ExemptPaths
|
|
16
|
+
# Probe set covering root-level and nested paths. A pattern matching
|
|
17
|
+
# every probe leaves nothing reviewable by definition. Dotfiles are
|
|
18
|
+
# excluded: wildcard patterns never match them under fnmatch pathname
|
|
19
|
+
# semantics (no FNM_DOTMATCH), so they cannot prove over-breadth.
|
|
20
|
+
OVER_BROAD_PROBES = ["README.md", "a.txt", "dir/b.txt", "a/b/c.txt"].freeze
|
|
21
|
+
|
|
22
|
+
# Conventional spellings authors use for "everything". fnmatch pathname
|
|
23
|
+
# semantics make bare "*" / "**" single-segment, but their intent is
|
|
24
|
+
# unmistakable — refuse them instead of silently exempting the world.
|
|
25
|
+
CONVENTIONAL_MATCH_ALL = ["*", "**"].freeze
|
|
26
|
+
|
|
27
|
+
# @param patterns [Object] raw exempt_paths config value
|
|
28
|
+
# @return [String, nil] refusal message, or nil when valid
|
|
29
|
+
def self.validate!(patterns)
|
|
30
|
+
return nil if patterns.nil?
|
|
31
|
+
|
|
32
|
+
unless patterns.is_a?(Array) && patterns.all? { |pattern| pattern.is_a?(String) && !pattern.strip.empty? }
|
|
33
|
+
return "exempt_paths must be a list of non-empty glob strings"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
patterns.each do |pattern|
|
|
37
|
+
if CONVENTIONAL_MATCH_ALL.include?(pattern) ||
|
|
38
|
+
OVER_BROAD_PROBES.all? { |probe| SubjectFilter.glob_match?(pattern, probe) }
|
|
39
|
+
return "exempt_paths pattern '#{pattern}' matches every path; refusing to exempt the world"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Partition changed paths (destination paths, as in the diff manifest)
|
|
46
|
+
# into exempt and non-exempt.
|
|
47
|
+
#
|
|
48
|
+
# @param paths [Array<String>]
|
|
49
|
+
# @param patterns [Array<String>, nil]
|
|
50
|
+
# @return [Hash] {exempt:, non_exempt:}
|
|
51
|
+
def self.classify(paths, patterns)
|
|
52
|
+
patterns = Array(patterns)
|
|
53
|
+
exempt, non_exempt = Array(paths).partition do |path|
|
|
54
|
+
patterns.any? { |pattern| SubjectFilter.glob_match?(pattern, path) }
|
|
55
|
+
end
|
|
56
|
+
{exempt: exempt, non_exempt: non_exempt}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -110,6 +110,7 @@ module Ace
|
|
|
110
110
|
reviewers: preset["reviewers"],
|
|
111
111
|
file_patterns: preset["file_patterns"],
|
|
112
112
|
file_pattern_groups: preset["file_pattern_groups"],
|
|
113
|
+
exempt_paths: preset["exempt_paths"],
|
|
113
114
|
budget: preset["budget"],
|
|
114
115
|
goals_brief: preset["goals_brief"],
|
|
115
116
|
review_role: preset["review_role"] || "scope",
|
|
@@ -52,6 +52,10 @@ module Ace
|
|
|
52
52
|
content_result = extract_review_content(config_result[:config], options)
|
|
53
53
|
return content_result unless content_result[:success]
|
|
54
54
|
|
|
55
|
+
# A no-op round (empty delta, or a fully review-exempt delta) is
|
|
56
|
+
# recorded without composing prompts or calling any model.
|
|
57
|
+
return save_noop_round(session_dir, options, config_result[:config], content_result) if content_result[:noop]
|
|
58
|
+
|
|
55
59
|
# Step 4: Compose prompts via ace-bundle
|
|
56
60
|
prompt_result = compose_review_prompt(
|
|
57
61
|
config_result[:config],
|
|
@@ -189,11 +193,21 @@ module Ace
|
|
|
189
193
|
# Merge options with config
|
|
190
194
|
options.merge_config(config)
|
|
191
195
|
|
|
196
|
+
# Review-exempt paths are validated at config load, naming the offender
|
|
197
|
+
if (exempt_error = Molecules::ExemptPaths.validate!(config[:exempt_paths]))
|
|
198
|
+
return {success: false,
|
|
199
|
+
error: "Invalid exempt_paths in review config for preset '#{preset_name}': #{exempt_error}"}
|
|
200
|
+
end
|
|
201
|
+
|
|
192
202
|
{success: true, config: config}
|
|
193
203
|
end
|
|
194
204
|
|
|
195
205
|
# Step 2: Extract subject and context
|
|
196
206
|
def extract_review_content(config, options)
|
|
207
|
+
if options.delta_requested? && !options.pr_review?
|
|
208
|
+
return {success: false, error: "--delta requires --pr <identifier>"}
|
|
209
|
+
end
|
|
210
|
+
|
|
197
211
|
# Handle PR mode
|
|
198
212
|
if options.pr_review?
|
|
199
213
|
return extract_pr_content(options.pr, config, options)
|
|
@@ -274,6 +288,8 @@ module Ace
|
|
|
274
288
|
|
|
275
289
|
# Extract PR content (diff and metadata)
|
|
276
290
|
def extract_pr_content(pr_identifier, config, options)
|
|
291
|
+
return extract_pr_delta_content(pr_identifier, config, options) if options.delta_requested?
|
|
292
|
+
|
|
277
293
|
# Fetch PR diff and metadata
|
|
278
294
|
fetch_options = options.gh_timeout ? {timeout: options.gh_timeout} : {}
|
|
279
295
|
result = Ace::Review::Molecules::GhPrFetcher.fetch_pr(pr_identifier, fetch_options)
|
|
@@ -296,6 +312,8 @@ module Ace
|
|
|
296
312
|
inventory.length == changed_files && inventory.map { |item| item["path"] }.sort == diff_files.sort
|
|
297
313
|
return {success: false, error: "PR file inventory does not match the fetched diff; review input may be incomplete"}
|
|
298
314
|
end
|
|
315
|
+
scoped = apply_exempt_paths(scoped, config)
|
|
316
|
+
return scoped unless scoped[:success]
|
|
299
317
|
scoped[:manifest][:pr_file_inventory_verified] = true
|
|
300
318
|
scoped[:manifest][:head_sha] = result[:metadata]["headRefOid"]
|
|
301
319
|
scoped[:manifest][:base_branch_sha] = result[:metadata]["baseRefOid"]
|
|
@@ -341,6 +359,200 @@ module Ace
|
|
|
341
359
|
}
|
|
342
360
|
end
|
|
343
361
|
|
|
362
|
+
# Extract PR content for a delta round: only the diff between a reference
|
|
363
|
+
# head (explicit, or auto-resolved from the most recent prior session of
|
|
364
|
+
# this PR) and the current head. Fails closed on missing sessions, rewritten
|
|
365
|
+
# history, or oversized deltas. An empty delta returns a no-op round marker.
|
|
366
|
+
def extract_pr_delta_content(pr_identifier, config, options)
|
|
367
|
+
fetch_options = options.gh_timeout ? {timeout: options.gh_timeout} : {}
|
|
368
|
+
metadata_result = Molecules::GhPrFetcher.fetch_metadata(pr_identifier, fetch_options)
|
|
369
|
+
return {success: false, error: metadata_result[:error]} unless metadata_result[:success]
|
|
370
|
+
|
|
371
|
+
metadata = metadata_result[:metadata]
|
|
372
|
+
unless %w[headRefOid baseRefOid].all? { |key| metadata[key].to_s.match?(/\A[0-9a-f]{40}\z/) }
|
|
373
|
+
return {success: false, error: "Delta review requires exact head/base SHAs"}
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
inventory = Molecules::GhPrFetcher.fetch_file_inventory(metadata, fetch_options)
|
|
377
|
+
return {success: false, error: inventory[:error]} unless inventory[:success]
|
|
378
|
+
metadata["files"] = inventory[:files]
|
|
379
|
+
|
|
380
|
+
delta = Molecules::DeltaResolver.resolve(options.delta, metadata, project_root: @project_root || Dir.pwd)
|
|
381
|
+
return {success: false, error: delta[:error]} unless delta[:success]
|
|
382
|
+
|
|
383
|
+
options.pr_metadata = metadata
|
|
384
|
+
# Carry the reference session's findings forward as evidence; an explicit
|
|
385
|
+
# --evidence-session still merges on top.
|
|
386
|
+
if delta[:session_dir]
|
|
387
|
+
options.evidence_sessions = [delta[:session_dir], *options.evidence_sessions].uniq
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
manifest = {
|
|
391
|
+
head_sha: metadata["headRefOid"],
|
|
392
|
+
delta_reference_head: delta[:reference_head],
|
|
393
|
+
delta_base_head: metadata["headRefOid"],
|
|
394
|
+
delta_reference_source: delta[:source].to_s,
|
|
395
|
+
delta_scope: "delta since #{delta[:reference_head]}"
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if delta[:diff].empty?
|
|
399
|
+
return {success: true, noop: true, noop_reason: :empty_delta,
|
|
400
|
+
subject: "", context: nil, cache_dir: options.session_dir || create_cache_directory,
|
|
401
|
+
pr_metadata: metadata, delta: delta, diff_manifest: manifest}
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
scoped = Molecules::DiffScope.select(delta[:diff], config[:file_patterns],
|
|
405
|
+
groups: config[:file_pattern_groups])
|
|
406
|
+
return scoped unless scoped[:success]
|
|
407
|
+
|
|
408
|
+
# Exempt-path acceptance applies to delta rounds: a delta whose files
|
|
409
|
+
# are all review-exempt is recorded as a no-op without model calls.
|
|
410
|
+
if Array(config[:exempt_paths]).any?
|
|
411
|
+
classification = Molecules::ExemptPaths.classify(scoped[:manifest][:selected_files], config[:exempt_paths])
|
|
412
|
+
if classification[:non_exempt].empty?
|
|
413
|
+
return {success: true, noop: true, noop_reason: :exempt_delta,
|
|
414
|
+
noop_scope: "delta touches only review-exempt paths (#{classification[:exempt].join(", ")}) " \
|
|
415
|
+
"matching patterns (#{Array(config[:exempt_paths]).join(", ")})",
|
|
416
|
+
subject: "", context: nil, cache_dir: options.session_dir || create_cache_directory,
|
|
417
|
+
pr_metadata: metadata, delta: delta,
|
|
418
|
+
diff_manifest: manifest.merge(scoped[:manifest]).merge(
|
|
419
|
+
exempt_files: classification[:exempt],
|
|
420
|
+
exempt_patterns: Array(config[:exempt_paths]))}
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
scoped = apply_exempt_paths(scoped, config)
|
|
424
|
+
return scoped unless scoped[:success]
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# The selected delta alone can still exceed the whole-prompt ceiling.
|
|
428
|
+
diff_tokens = (Atoms::TokenEstimator.estimate(scoped[:diff]) * Atoms::PromptBudget::ESTIMATE_SAFETY_FACTOR).ceil
|
|
429
|
+
if diff_tokens > Atoms::PromptBudget::DEFAULT_INPUT_LIMIT
|
|
430
|
+
return {success: false, error: "Review packet exceeds budget: selected delta alone ~#{diff_tokens} tokens exceeds #{Atoms::PromptBudget::DEFAULT_INPUT_LIMIT}; review the delta in coherent module/lens scopes"}
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
{
|
|
434
|
+
success: true,
|
|
435
|
+
subject: scoped[:diff],
|
|
436
|
+
context: format_pr_metadata(metadata),
|
|
437
|
+
cache_dir: options.session_dir || create_cache_directory,
|
|
438
|
+
pr_metadata: metadata,
|
|
439
|
+
delta: delta,
|
|
440
|
+
diff_manifest: manifest.merge(scoped[:manifest])
|
|
441
|
+
}
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Record a no-op round (empty or fully review-exempt delta) as a complete
|
|
445
|
+
# session: metadata + report, zero model calls. Prior findings are carried
|
|
446
|
+
# forward as evidence; unreadable evidence is stated, never pretended.
|
|
447
|
+
def save_noop_round(session_dir, options, config, content)
|
|
448
|
+
review_data = {
|
|
449
|
+
preset: options.preset,
|
|
450
|
+
review_role: config[:review_role],
|
|
451
|
+
pr_url: options.pr_metadata&.dig("url"),
|
|
452
|
+
evidence_sessions: options.evidence_sessions,
|
|
453
|
+
model: nil,
|
|
454
|
+
context: content[:context],
|
|
455
|
+
subject: content[:subject],
|
|
456
|
+
diff_manifest: content[:diff_manifest],
|
|
457
|
+
noop: true,
|
|
458
|
+
noop_reason: content[:noop_reason],
|
|
459
|
+
delta: content[:delta]
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
metadata = create_metadata(review_data).merge(
|
|
463
|
+
"noop_round" => true,
|
|
464
|
+
"noop_reason" => content[:noop_reason].to_s,
|
|
465
|
+
"delta" => content[:delta] ? {
|
|
466
|
+
"reference_head" => content[:delta][:reference_head],
|
|
467
|
+
"reference_source" => content[:delta][:source].to_s
|
|
468
|
+
} : nil
|
|
469
|
+
)
|
|
470
|
+
File.write(File.join(session_dir, "metadata.yml"), YAML.dump(metadata))
|
|
471
|
+
|
|
472
|
+
File.write(File.join(session_dir, "review.md"), render_noop_report(options, content))
|
|
473
|
+
|
|
474
|
+
{
|
|
475
|
+
success: true,
|
|
476
|
+
session_dir: session_dir,
|
|
477
|
+
output_file: File.join(session_dir, "review.md"),
|
|
478
|
+
noop: true,
|
|
479
|
+
message: "No-op review round recorded in #{session_dir} (no model calls)"
|
|
480
|
+
}
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def render_noop_report(options, content)
|
|
484
|
+
delta = content[:delta]
|
|
485
|
+
lines = []
|
|
486
|
+
lines << "---"
|
|
487
|
+
lines << "noop_round: true"
|
|
488
|
+
lines << "noop_reason: #{content[:noop_reason]}"
|
|
489
|
+
if delta
|
|
490
|
+
lines << "delta_reference_head: #{delta[:reference_head]}"
|
|
491
|
+
lines << "delta_reference_source: #{delta[:source]}"
|
|
492
|
+
end
|
|
493
|
+
lines << "pr_url: #{options.pr_metadata&.dig("url")}"
|
|
494
|
+
lines << "timestamp: #{Time.now.iso8601}"
|
|
495
|
+
lines << "---"
|
|
496
|
+
lines << ""
|
|
497
|
+
lines << "# No-op review round"
|
|
498
|
+
lines << ""
|
|
499
|
+
if content[:noop_reason] == :empty_delta && delta
|
|
500
|
+
lines << "Scope: delta since `#{delta[:reference_head]}` — the delta is empty; there is no change to review."
|
|
501
|
+
else
|
|
502
|
+
lines << "Scope: #{content[:noop_scope] || "no-op"}."
|
|
503
|
+
end
|
|
504
|
+
lines << ""
|
|
505
|
+
lines << "Verdict: no new findings. This round is clean only if no carried-forward Critical/High finding remains unresolved."
|
|
506
|
+
lines << ""
|
|
507
|
+
if Array(options.evidence_sessions).any?
|
|
508
|
+
evidence = Molecules::ReviewEvidence.build(session_dirs: options.evidence_sessions,
|
|
509
|
+
pr_metadata: options.pr_metadata || {})
|
|
510
|
+
if evidence[:success]
|
|
511
|
+
lines << "## Carried-forward findings"
|
|
512
|
+
lines << ""
|
|
513
|
+
lines << evidence[:content]
|
|
514
|
+
else
|
|
515
|
+
lines << "## Evidence not carried forward"
|
|
516
|
+
lines << ""
|
|
517
|
+
lines << "Prior session evidence could not be read (#{evidence[:error]}); this round did NOT carry findings forward."
|
|
518
|
+
end
|
|
519
|
+
else
|
|
520
|
+
lines << "## Evidence not carried forward"
|
|
521
|
+
lines << ""
|
|
522
|
+
lines << "No prior session was available to carry findings forward."
|
|
523
|
+
end
|
|
524
|
+
lines.join("\n")
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Drop review-exempt paths from a scoped diff. Matching paths are removed
|
|
528
|
+
# from the subject and enumerated in the manifest, in every round type.
|
|
529
|
+
# A full round whose files are all exempt is refused: full rounds are
|
|
530
|
+
# explicit operator intent, and an empty subject cannot be reviewed.
|
|
531
|
+
def apply_exempt_paths(scoped, config)
|
|
532
|
+
patterns = Array(config[:exempt_paths])
|
|
533
|
+
return scoped if patterns.empty?
|
|
534
|
+
|
|
535
|
+
classification = Molecules::ExemptPaths.classify(scoped[:manifest][:selected_files], patterns)
|
|
536
|
+
return scoped if classification[:exempt].empty?
|
|
537
|
+
|
|
538
|
+
exempt_set = classification[:exempt]
|
|
539
|
+
blocks = Atoms::DiffBoundaryFinder.parse(scoped[:diff]).reject { |block| exempt_set.include?(block[:path]) }
|
|
540
|
+
|
|
541
|
+
if blocks.empty?
|
|
542
|
+
return {success: false,
|
|
543
|
+
error: "Every changed file is review-exempt (#{exempt_set.join(", ")}, patterns: #{patterns.join(", ")}); " \
|
|
544
|
+
"a full round cannot review an empty subject — use a delta round for exempt acceptance"}
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
manifest = scoped[:manifest].merge(
|
|
548
|
+
exempt_files: classification[:exempt],
|
|
549
|
+
exempt_patterns: patterns,
|
|
550
|
+
selected_files: blocks.map { |block| block[:path] },
|
|
551
|
+
selected_sha256: Digest::SHA256.hexdigest(blocks.map { |block| block[:content] }.join)
|
|
552
|
+
)
|
|
553
|
+
{success: true, diff: blocks.map { |block| block[:content] }.join, manifest: manifest}
|
|
554
|
+
end
|
|
555
|
+
|
|
344
556
|
# Format PR metadata for context
|
|
345
557
|
def format_pr_metadata(metadata)
|
|
346
558
|
info = "## Pull Request Information\n\n"
|
data/lib/ace/review/version.rb
CHANGED
data/lib/ace/review.rb
CHANGED
|
@@ -47,7 +47,8 @@ require_relative "review/molecules/feedback_file_reader"
|
|
|
47
47
|
require_relative "review/molecules/feedback_directory_manager"
|
|
48
48
|
require_relative "review/molecules/feedback_synthesizer"
|
|
49
49
|
require_relative "review/molecules/subject_filter"
|
|
50
|
-
require_relative "review/molecules/
|
|
50
|
+
require_relative "review/molecules/delta_resolver"
|
|
51
|
+
require_relative "review/molecules/exempt_paths"
|
|
51
52
|
require_relative "review/molecules/pr_task_spec_resolver"
|
|
52
53
|
require_relative "review/molecules/goals_brief"
|
|
53
54
|
|