ace-review 0.55.0 → 0.56.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bccdbe28f93f46e3fb13b54474acd6e3f10b4b6b01e0b1259c1f9b2e3395f184
4
- data.tar.gz: 6b948eea0b8a90354d71c5c233d3e2674fd550687f76d477374218d3e8abc820
3
+ metadata.gz: a3eb68d4c56fe426002098bd5d5dc10567ea5dcf92ba709069d526c9d8d4ca77
4
+ data.tar.gz: bdaafd88e24669a1c2beb50fd16b2daf606f56b7f07d0815a36ba28deda175a3
5
5
  SHA512:
6
- metadata.gz: d4a3e6588a9e1808b086aa729062f407a4f89ff7d64269e0e4af57f8cec29933da7170388ad0ed998dbe3a504c96039d6bc4a2b9800f95638822eb3838ad78b3
7
- data.tar.gz: 72153ae476b7416db5a286384c8fd2c023490ba1d3cb8a67a3b3d1f20dffc5c6ab46caa403348b54d6c84ee7511d3199436ae24870f23c4308621ea63c1f9587
6
+ metadata.gz: c1acb46ca00a76f48bd05e65f7b167e9bb395732a922bf848a8792fd342be10e85777e155eb6ba5675396a6021bd257fd9d3008dcc26a03656768f83931d9338
7
+ data.tar.gz: 23c76466b28055d2c02364e711373d2743b71684b3dcb611c2523e344c430546034af289d972c3af80d590bb221f61cef8b2d977c988d334fcd620398c1467c6
@@ -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
- # Subject strategy configuration
45
- # Controls how large diffs are handled when they exceed model context limits
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
- # Strategy Types:
48
- # - adaptive (default): Auto-selects full or chunked based on model capabilities
49
- # - full: Send complete diff in single request (for large context models)
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,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.56.1] - 2026-09-29
11
+
12
+ ### Fixed
13
+ - A full PR review round is no longer mistaken for a delta round. dry-cli yields
14
+ nil for an absent `--delta` and for a bare valueless `--delta`, and both were
15
+ normalized to auto-resolution — so the first-ever review of any new PR failed
16
+ with "No prior review session records a head". Nil now stays a full round;
17
+ auto-resolution is the explicit `--delta auto` value (explicit heads unchanged).
18
+ - The released gemspec now declares `ace-git-github ~> 0.2`, matching the source
19
+ constraint: the previously published 0.56.0 metadata still pinned `~> 0.1.1`,
20
+ which kept published installations resolving ace-git-github 0.1.2 and made
21
+ the released 0.2.0 unreachable through dependency resolution.
22
+
23
+ ## [0.56.0] - 2026-09-27
24
+
25
+ ### Added
26
+
27
+ - 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.
28
+ - 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.
29
+
30
+ ### Removed
31
+
32
+ - 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).
33
+
34
+ ### Changed
35
+
36
+ - 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.
37
+
10
38
  ## [0.55.0] - 2026-09-24
11
39
 
12
40
  ### 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 `auto` to auto-resolve from the most recent prior session for this PR); omit for a full round"
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,25 @@ module Ace
152
157
  options
153
158
  end
154
159
 
160
+ # Normalize --delta: the explicit value "auto" resolves the reference
161
+ # head from prior sessions (:auto); any other value is the head itself.
162
+ # dry-cli cannot distinguish an absent --delta from a bare one (both
163
+ # arrive as nil), and a full round must never be mistaken for a delta
164
+ # round — so nil stays a full round and auto-resolution is spelled out.
165
+ def process_delta(options)
166
+ return unless options.key?(:delta)
167
+
168
+ value = options[:delta]
169
+ value = value.to_s.strip if value.is_a?(String)
170
+ options[:delta] = if value.nil? || value.empty?
171
+ nil
172
+ elsif value.casecmp("auto").zero?
173
+ :auto
174
+ else
175
+ value
176
+ end
177
+ end
178
+
155
179
  def process_subjects(options)
156
180
  return unless options[:subject]
157
181
 
@@ -138,9 +138,6 @@ module Ace
138
138
 
139
139
  # Raised when context composition fails
140
140
  class ContextComposerError < Error; end
141
-
142
- # Raised when an unknown review strategy is requested
143
- class UnknownStrategyError < Error; end
144
141
  end
145
142
  end
146
143
  end
@@ -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",
@@ -21,7 +21,14 @@ module Ace
21
21
  if metadata["pr_url"] && metadata["pr_url"] != pr_metadata["url"]
22
22
  return {success: false, error: "#{dir} belongs to a different PR"}
23
23
  end
24
- reports = Dir.glob(File.join(dir, "review-report-*.md"))
24
+ # Reviewer reports are written as review-report-<model>.md (Ruby API
25
+ # path) or review-<model>.md (CLI provider path); prompt/context and
26
+ # dev-feedback files are not reports.
27
+ reports = Dir.glob(File.join(dir, "review-*.md")).select do |path|
28
+ base = File.basename(path)
29
+ !base.end_with?(".prompt.md", ".context.md") &&
30
+ !%w[review-dev-feedback.md].include?(base)
31
+ end
25
32
  return {success: false, error: "No review report in #{dir}"} if reports.empty?
26
33
 
27
34
  lines += ["", "## #{File.basename(dir)} — #{metadata["preset"]}",
@@ -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"