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. A later phase drops any finding whose
10//! line is not in that set, because such a finding is about code the model was
11//! never 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: 1.x returned an empty finding list for anything over 32k chars, which
40/// under this codebase's contract is the banned move - a file drep declined to
41/// analyze is not clean.
42///
43/// `u64` rather than `usize` so it compares directly against the byte counts
44/// `FailureReason` carries; only one site measures a `str` length, and that one
45/// casts.
46pub const PAYLOAD_MAX_BYTES: u64 = 256 * 1024;
47
48/// A rendered payload plus the file line numbers it legitimately covers.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct Payload {
51 /// The text handed to the model.
52 pub text: String,
53 /// Every new-file line number that appears in `text` with a number in the
54 /// gutter — `Context` and `Added` lines. A later phase drops any finding
55 /// whose line is not in this set, because such a finding is about code the
56 /// model was never shown.
57 ///
58 /// Context lines are included deliberately: they were shown with their
59 /// real numbers, so a finding on one is an observation about code the
60 /// model actually read, not a hallucination.
61 pub valid_lines: BTreeSet<u32>,
62}
63
64/// Render every hunk belonging to one file into a single payload.
65///
66/// The file path is taken from the hunks themselves rather than passed
67/// alongside them — they all carry it, and a separate argument would be a
68/// second copy for the caller to keep in sync with no way to check it.
69/// `hunks` must therefore all share a `file_path`; they are rendered in
70/// ascending `new_start` order. Returns `None` when `hunks` is empty.
71///
72/// The language arrives as a [`LanguageSupport`], not a string: `languages/`
73/// is the only place a language is named, and `display_name` is the name
74/// meant for the model. A bare `&str` here would let a caller pass the
75/// registry key (`"rust"`) where the prompt wants `"Rust"`, with nothing to
76/// catch it.
77pub fn render(language: &LanguageSupport, hunks: &[Hunk]) -> Option<Payload> {
78 let file_path = &hunks.first()?.file_path;
79
80 let mut sorted: Vec<&Hunk> = hunks.iter().collect();
81 sorted.sort_by_key(|h| h.new_start);
82
83 let mut text = String::new();
84 let mut valid_lines: BTreeSet<u32> = BTreeSet::new();
85
86 // `write!` into the buffer rather than `push_str(&format!(..))`: the latter
87 // allocates a throwaway `String` for every rendered line, and a payload is
88 // routinely well over a thousand lines. Writing to a `String` cannot fail,
89 // so the `Result` is discarded deliberately.
90 let _ = writeln!(text, "File: {}", file_path.display());
91 let _ = writeln!(text, "Language: {}", language.display_name);
92 text.push('\n');
93 text.push_str(scope_sentence(&sorted));
94 text.push('\n');
95 text.push('\n');
96
97 let mut last_numbered: Option<u32> = None;
98
99 for hunk in &sorted {
100 if let Some(prev) = last_numbered {
101 let gap = hunk.new_start.saturating_sub(prev.saturating_add(1));
102 if gap > 0 {
103 let _ = writeln!(text, "... {gap} lines omitted ...");
104 }
105 }
106
107 for (number, line) in hunk.numbered_lines() {
108 match number {
109 Some(n) => {
110 let _ = writeln!(text, "{}{n:>6} | {}", line.marker(), line.content());
111 valid_lines.insert(n);
112 last_numbered = Some(n);
113 }
114 // A removed line: six spaces where the number goes. It has no
115 // line in the new file, and inventing one is the error this
116 // whole format exists to prevent.
117 None => {
118 let _ = writeln!(text, "{}{:>6} | {}", line.marker(), "", line.content());
119 }
120 }
121 }
122 }
123
124 Some(Payload { text, valid_lines })
125}
126
127/// Pick the right scope sentence for this set of hunks.
128///
129/// Whole-file mode (every line is `Context`) tells the model to review the
130/// whole file; diff mode (some line is `Added` or `Removed`) tells it to focus
131/// on the marked lines and never report findings on removed lines. The
132/// decision reads from the data so the caller cannot get a flag wrong; it is
133/// sound because git never emits a hunk with no changed line, as noted on
134/// [`Hunk::whole_file`].
135fn scope_sentence(hunks: &[&Hunk]) -> &'static str {
136 let has_change = hunks
137 .iter()
138 .any(|h| h.lines.iter().any(|l| l.marker() != ' '));
139 if has_change {
140 "Review the lines marked `+`. Lines with no marker are unchanged context. \
141 Lines marked `-` were removed and have no line number; do not report \
142 findings on them. Report each finding using the line number shown in the gutter."
143 } else {
144 "Review the entire file. Report each finding using the line number shown in the gutter."
145 }
146}