Skip to main content

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}