Skip to main content

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;