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. 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}