drep/analysis/payload.rs
1//! The text the LLM sees, plus the line numbers that text legitimately
2//! covers.
3//!
4//! The format exists to solve one problem: **line-number provenance**. If the
5//! model is handed a bare diff it must infer file line numbers from the `@@`
6//! header, which it does unreliably; every finding then points at the wrong
7//! code and looks perfectly plausible. So the payload states each line's real
8//! file line number explicitly in the gutter, and the caller keeps the set of
9//! numbers that were actually shown. The analyzer drops any finding whose line
10//! is not in that set, because such a finding is about code the model was never
11//! shown.
12//!
13//! The numbering itself is not implemented here: it comes from
14//! [`Hunk::numbered_lines`], which is the single home of the rule that a
15//! removed line does not consume a line number.
16
17use std::collections::BTreeSet;
18use std::fmt::Write as _;
19
20use crate::diff::hunks::Hunk;
21use crate::languages::spec::LanguageSupport;
22
23/// The largest payload drep will send to the model, in bytes.
24///
25/// Declared here, beside the thing it measures, and **enforced by
26/// [`crate::analysis::code_quality::CodeQualityAnalyzer::analyze_file`]** on
27/// the text `render` returns - `render` itself has no size opinion and no way
28/// to report one, since it returns `Option<Payload>` and `None` already means
29/// "no hunks".
30///
31/// It used to live in `cli::check` and was consulted only in paths mode, so a
32/// newly-added 5 MB file reached the model whole through `--staged` or
33/// `--diff`, the two modes a commit gate actually runs in. The check belongs
34/// on the rendered payload because that is the one thing every input mode
35/// produces.
36///
37/// A payload over the ceiling is a
38/// [`crate::analysis::result::FailureReason::PayloadTooLarge`] failure, never a
39/// skip: a file drep declined to analyze is not clean.
40///
41/// `u64` rather than `usize` so it compares directly against the byte counts
42/// `FailureReason` carries; only one site measures a `str` length, and that one
43/// casts.
44pub const PAYLOAD_MAX_BYTES: u64 = 256 * 1024;
45
46/// A rendered payload plus the file line numbers it legitimately covers.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct Payload {
49 /// The text handed to the model.
50 pub text: String,
51 /// Every new-file line number that appears in `text` with a number in the
52 /// gutter — `Context` and `Added` lines. The analyzer drops any finding
53 /// whose line is not in this set, because such a finding is about code the
54 /// model was never shown.
55 ///
56 /// Context lines are included deliberately: they were shown with their
57 /// real numbers, so a finding on one is an observation about code the
58 /// model actually read, not a hallucination.
59 pub valid_lines: BTreeSet<u32>,
60}
61
62/// Render every hunk belonging to one file into a single payload.
63///
64/// The file path is taken from the hunks themselves rather than passed
65/// alongside them — they all carry it, and a separate argument would be a
66/// second copy for the caller to keep in sync with no way to check it.
67/// `hunks` must therefore all share a `file_path`; they are rendered in
68/// ascending `new_start` order. Returns `None` when `hunks` is empty.
69///
70/// The language arrives as a [`LanguageSupport`], not a string: `languages/`
71/// is the only place a language is named, and `display_name` is the name
72/// meant for the model. A bare `&str` here would let a caller pass the
73/// registry key (`"rust"`) where the prompt wants `"Rust"`, with nothing to
74/// catch it.
75pub fn render(language: &LanguageSupport, hunks: &[Hunk]) -> Option<Payload> {
76 let file_path = &hunks.first()?.file_path;
77
78 let mut sorted: Vec<&Hunk> = hunks.iter().collect();
79 sorted.sort_by_key(|h| h.new_start);
80
81 let mut text = String::new();
82 let mut valid_lines: BTreeSet<u32> = BTreeSet::new();
83
84 // `write!` into the buffer rather than `push_str(&format!(..))`: the latter
85 // allocates a throwaway `String` for every rendered line, and a payload is
86 // routinely well over a thousand lines. Writing to a `String` cannot fail,
87 // so the `Result` is discarded deliberately.
88 let _ = writeln!(text, "File: {}", file_path.display());
89 let _ = writeln!(text, "Language: {}", language.display_name);
90 text.push('\n');
91 text.push_str(scope_sentence(&sorted));
92 text.push('\n');
93 text.push('\n');
94
95 let mut last_numbered: Option<u32> = None;
96
97 for hunk in &sorted {
98 if let Some(prev) = last_numbered {
99 let gap = hunk.new_start.saturating_sub(prev.saturating_add(1));
100 if gap > 0 {
101 let _ = writeln!(text, "... {gap} lines omitted ...");
102 }
103 }
104
105 for (number, line) in hunk.numbered_lines() {
106 match number {
107 Some(n) => {
108 let _ = writeln!(text, "{}{n:>6} | {}", line.marker(), line.content());
109 valid_lines.insert(n);
110 last_numbered = Some(n);
111 }
112 // A removed line: six spaces where the number goes. It has no
113 // line in the new file, and inventing one is the error this
114 // whole format exists to prevent.
115 None => {
116 let _ = writeln!(text, "{}{:>6} | {}", line.marker(), "", line.content());
117 }
118 }
119 }
120 }
121
122 Some(Payload { text, valid_lines })
123}
124
125/// Pick the right scope sentence for this set of hunks.
126///
127/// Whole-file mode (every line is `Context`) tells the model to review the
128/// whole file; diff mode (some line is `Added` or `Removed`) tells it to focus
129/// on the marked lines and never report findings on removed lines. The
130/// decision reads from the data so the caller cannot get a flag wrong; it is
131/// sound because git never emits a hunk with no changed line, as noted on
132/// [`Hunk::whole_file`].
133fn scope_sentence(hunks: &[&Hunk]) -> &'static str {
134 let has_change = hunks
135 .iter()
136 .any(|h| h.lines.iter().any(|l| l.marker() != ' '));
137 if has_change {
138 "Review the lines marked `+`. Lines with no marker are unchanged context. \
139 Lines marked `-` were removed and have no line number; do not report \
140 findings on them. Report each finding using the line number shown in the gutter."
141 } else {
142 "Review the entire file. Report each finding using the line number shown in the gutter."
143 }
144}