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