git_policy_cli/standards_review_input.rs
1//! What:
2//! The pure part of the standards-review shadow run: which landed commits get an entry, and the capped model input
3//! built from the commit's patch and the excerpts of its changed code files.
4//! Why:
5//! This is the root `mise.toml` task `shadow-review` after its Git reads: the no-entry, no-code-files and review
6//! cases, the 2000-line patch cap, the eight-file and 4000-line excerpt caps, and the 24000-unit input cap. Taking
7//! the patch and the file contents as plain values lets the caps be tested without Git, a network or a clock.
8//!
9//! In TS you'd write (pseudocode):
10//! ```ts
11//! const plan = classify(names); const input = buildReviewInput({ commit });
12//! ```
13
14use super::standards_review_patch::{NewSpan, new_spans};
15use super::standards_review_text::{utf16_length, utf16_prefix};
16use std::collections::BTreeMap;
17
18/// The shadow log's repository-relative path; a commit that changes nothing else is the hook's own record.
19pub const LOG_RELATIVE: &str = "doc/audit/standards-review-gate-shadow.md";
20
21/// Patch lines kept for the model.
22pub const CAP_LINES: usize = 2000;
23
24/// Code files that get an excerpt.
25pub const MAX_EXCERPT_FILES: usize = 8;
26
27/// Lines of context kept around the changed span of an excerpted file.
28pub const EXCERPT_CONTEXT: i64 = 30;
29
30/// Excerpt lines after which no further file gets an excerpt.
31pub const EXCERPT_LINE_CAP: i64 = 4000;
32
33/// Lines of the head of a file that has no changed line to anchor an excerpt on: the context on both sides and one.
34pub const HEAD_WINDOW: i64 = 2 * EXCERPT_CONTEXT + 1;
35
36/// UTF-16 units of model input.
37pub const INPUT_UNIT_CAP: usize = 24000;
38
39/// Name endings that count as code.
40pub const CODE_SUFFIXES: [&str; 6] = [".ts", ".tsx", ".rs", ".js", ".mjs", ".cjs"];
41
42/// What:
43/// What a landed commit's changed names say before anything is read from Git.
44/// Why:
45/// A commit with no names (a root commit, a merge) or only the log gets no entry, and a commit with no code gets a
46/// three-line skip entry; neither needs the patch or any file, so neither costs a Git start.
47///
48/// In TS you'd write (pseudocode):
49/// ```ts
50/// type Plan = { kind: 'silent' } | { kind: 'no-code' } | { kind: 'review'; codeFiles: string[] };
51/// ```
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub enum Plan {
54 /// No entry at all.
55 Silent,
56 /// The skip entry.
57 NoCode,
58 /// A review of these code files, in name order.
59 Review(Vec<String>),
60}
61
62/// What:
63/// The plan for the names `git diff-tree --name-only -r` prints for the landed commit.
64/// Why:
65/// It is the script's first three branches, in order.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// if (names.every((name) => name === logRelative)) return silent; if (codeFiles.length === 0) return noCode;
70/// ```
71pub fn plan_for(names: &[String]) -> Plan {
72 if names.iter().all(|name| return name == LOG_RELATIVE) {
73 return Plan::Silent;
74 }
75 let code_files: Vec<String> = names
76 .iter()
77 .filter(|name| return CODE_SUFFIXES.iter().any(|suffix| return name.ends_with(suffix)))
78 .cloned()
79 .collect();
80 if code_files.is_empty() {
81 return Plan::NoCode;
82 }
83 return Plan::Review(code_files);
84}
85
86/// What:
87/// Everything the review reads about one landed commit: the patch, and the new-side text of each code file that
88/// will be excerpted. A path missing from `contents` has no new-side file (it was deleted).
89/// Why:
90/// One value is all the pure builder needs, whichever way the reads were made.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// type LandedCommit = { sha: string; subject: string; patch: string; contents: Map<string, string> };
95/// ```
96#[derive(Clone, Debug, Default, Eq, PartialEq)]
97pub struct LandedCommit {
98 /// Full commit name.
99 pub sha: String,
100 /// Subject line, trimmed.
101 pub subject: String,
102 /// `git show --format= --patch` text.
103 pub patch: String,
104 /// New-side text by path.
105 pub contents: BTreeMap<String, String>,
106}
107
108/// What:
109/// The sizes the entry reports and the text the model receives.
110/// Why:
111/// `model_input` is cut at the unit cap, while `model_input_units` is the length the script prints: the cut length.
112///
113/// In TS you'd write (pseudocode):
114/// ```ts
115/// type ReviewInput = { codeFileCount: number; diffLineCount: number; truncated: boolean; modelInput: string; modelInputUnits: number };
116/// ```
117#[derive(Clone, Debug, Eq, PartialEq)]
118pub struct ReviewInput {
119 /// Code files in the commit.
120 pub code_file_count: usize,
121 /// Lines of the whole patch (a final newline counts as one more, empty, line).
122 pub diff_line_count: usize,
123 /// Whether the patch is over `CAP_LINES`.
124 pub truncated: bool,
125 /// The text sent to the model.
126 pub model_input: String,
127 /// The reported length of `model_input` in UTF-16 units.
128 pub model_input_units: usize,
129}
130
131/// What:
132/// The excerpt of one file: its header and the text of the window around the changed span.
133/// The window runs from `EXCERPT_CONTEXT` lines before the first changed new-side line to `EXCERPT_CONTEXT` lines
134/// after the last, cut from the new-side file. A file with no changed line to anchor on (a binary file, a mode change,
135/// a pure rename) gets the head of the file instead.
136/// Why:
137/// Returns the window's line count too, which the 4000-line cap adds up. The script cut the window from old-side
138/// numbers, which sit in the wrong place after an earlier insertion, and gave a file without hunks a nonsensical
139/// negative window.
140///
141/// In TS you'd write (pseudocode):
142/// ```ts
143/// excerptParts.push(`// candidate excerpt of ${name}, lines ${from} to ${to} of ${fileLines.length}`);
144/// ```
145fn excerpt(name: &str, content: &str, spans: &[NewSpan]) -> (Vec<String>, i64) {
146 let file_lines: Vec<&str> = content.split('\n').collect();
147 let total: i64 = i64::try_from(file_lines.len()).unwrap_or(i64::MAX);
148 let first_changed: Option<i64> = spans.iter().map(|span| return i64::try_from(span.first).unwrap_or(i64::MAX)).min();
149 let last_changed: Option<i64> = spans.iter().map(|span| return i64::try_from(span.last).unwrap_or(i64::MAX)).max();
150 let (from, to) = match (first_changed, last_changed) {
151 (Some(first), Some(last)) => (1.max(first.saturating_sub(EXCERPT_CONTEXT)), total.min(last.saturating_add(EXCERPT_CONTEXT))),
152 _ => (1, total.min(HEAD_WINDOW)),
153 };
154 let header: String = format!("// candidate excerpt of {name}, lines {from} to {to} of {total}");
155 let begin: usize = usize::try_from(from - 1).unwrap_or(0);
156 let end: usize = usize::try_from(to).unwrap_or(0);
157 let body: String = if begin < end { file_lines[begin..end].join("\n") } else { String::new() };
158 return (vec![header, body], to - from + 1);
159}
160
161/// What:
162/// Build the capped model input for a reviewed commit.
163/// Why:
164/// The input is the first `CAP_LINES` patch lines, a blank line, then each excerpt separated by blank lines, cut to
165/// `INPUT_UNIT_CAP` UTF-16 units.
166///
167/// In TS you'd write (pseudocode):
168/// ```ts
169/// const lunaInput = (lines.slice(0, capLines).join('\n') + '\n\n' + excerptParts.join('\n\n')).slice(0, 24000);
170/// ```
171pub fn build_input(commit: &LandedCommit, code_files: &[String]) -> ReviewInput {
172 let lines: Vec<&str> = commit.patch.split('\n').collect();
173 let kept: &[&str] = &lines[..lines.len().min(CAP_LINES)];
174 let mut parts: Vec<String> = Vec::new();
175 let mut excerpt_lines: i64 = 0;
176 for name in code_files.iter().take(MAX_EXCERPT_FILES) {
177 if excerpt_lines >= EXCERPT_LINE_CAP {
178 break;
179 }
180 let Some(content) = commit.contents.get(name) else {
181 continue;
182 };
183 let spans: Vec<NewSpan> = new_spans(lines.as_slice(), name);
184 let (pieces, covered) = excerpt(name, content, spans.as_slice());
185 parts.extend(pieces);
186 excerpt_lines += covered;
187 }
188 let full: String = format!("{}\n\n{}", kept.join("\n"), parts.join("\n\n"));
189 let units: usize = utf16_length(full.as_str()).min(INPUT_UNIT_CAP);
190 return ReviewInput {
191 code_file_count: code_files.len(),
192 diff_line_count: lines.len(),
193 truncated: lines.len() > CAP_LINES,
194 model_input: utf16_prefix(full.as_str(), INPUT_UNIT_CAP),
195 model_input_units: units,
196 };
197}
198
199/// Controls stay out of the release executable.
200#[cfg(test)]
201#[path = "standards_review_input_tests.rs"]
202mod tests;