Skip to main content

spar/
style.rs

1//! Two gates over every string spar sends to GitHub.
2//!
3//! **Style.** Models comply unreliably with negative instructions, especially
4//! over a long run, so prompting is necessary but not sufficient. Every commit
5//! message, PR body, and comment is scrubbed deterministically and then
6//! re-verified. A leak is a hard error, not a warning.
7//!
8//! **Concision.** The reader of a PR is a human with other work. Model prose
9//! defaults to three paragraphs where one sentence would do, and asking nicely
10//! has the same reliability problem as asking for no em-dashes. So spar
11//! composes every comment itself from structured fields and clips each field to
12//! a budget, rather than forwarding whatever the model felt like writing.
13
14use std::sync::LazyLock;
15
16use regex::Regex;
17
18/// Figure dash, en dash, em dash, horizontal bar. The Python original caught
19/// only en and em; a model that reaches for U+2015 should not slip through.
20const DASHES: &str = r"[\x{2012}-\x{2015}]";
21
22static ATTRIBUTION_LINE: LazyLock<Regex> = LazyLock::new(|| {
23    Regex::new(concat!(
24        r"(?im)^\s*(?:",
25        r"co-authored-by:\s*(?:claude|codex|openai|chatgpt|anthropic|gpt).*",
26        r"|\x{1F916}?\s*generated with .*",
27        r"|.*\bwritten by (?:claude|codex|chatgpt|an? ai)\b.*",
28        r"|assisted[- ]by:.*",
29        r")\s*$",
30    ))
31    .expect("attribution line pattern")
32});
33
34static ATTRIBUTION_INLINE: LazyLock<Regex> = LazyLock::new(|| {
35    Regex::new(concat!(
36        r"(?i)\b(?:",
37        r"generated (?:with|by) (?:claude|codex|openai|chatgpt|ai)",
38        r"|(?:written|authored|created) (?:with|by) (?:claude|codex|chatgpt|ai)",
39        r"|with the help of (?:claude|codex|chatgpt|ai)",
40        r"|using (?:claude code|codex|chatgpt)",
41        r"|ai[- ]generated",
42        r"|as an ai\b",
43        r")",
44    ))
45    .expect("attribution inline pattern")
46});
47
48static DASH_RUN: LazyLock<Regex> =
49    LazyLock::new(|| Regex::new(&format!(r"[ \t]*{DASHES}[ \t]*")).expect("dash pattern"));
50
51static ANY_DASH: LazyLock<Regex> = LazyLock::new(|| Regex::new(DASHES).expect("dash class"));
52
53static TRAILING_SPACE: LazyLock<Regex> =
54    LazyLock::new(|| Regex::new(r"(?m)[ \t]+$").expect("trailing space pattern"));
55
56static BLANK_RUN: LazyLock<Regex> =
57    LazyLock::new(|| Regex::new(r"\n{3,}").expect("blank run pattern"));
58
59static HEADING: LazyLock<Regex> =
60    LazyLock::new(|| Regex::new(r"^\s{0,3}#{1,6}\s+\S").expect("heading pattern"));
61
62/// Headings that only announce that a body follows. Dropping them costs the
63/// reader nothing and saves them a line.
64static NOISE_HEADING: LazyLock<Regex> = LazyLock::new(|| {
65    Regex::new(
66        r"(?i)^\s{0,3}#{1,6}\s*(summary|description|overview|context|details?|background)\s*:?\s*$",
67    )
68    .expect("noise heading pattern")
69});
70
71/// Everything the two gates need to know. Mirrors the `[style]` config block.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct Style {
74    pub ban_em_dash: bool,
75    pub ban_ai_attribution: bool,
76    /// Enforce the length budgets below. Off means model prose passes through
77    /// at whatever length it arrived at.
78    pub terse: bool,
79    /// A finding's explanatory detail, as shown in the PR thread.
80    pub max_detail_chars: usize,
81    /// A one-line verdict or disposition summary.
82    pub max_summary_chars: usize,
83    /// A filed issue's body, or a PR body.
84    pub max_body_chars: usize,
85    /// A finding title, issue title, or PR title.
86    pub max_title_chars: usize,
87}
88
89impl Default for Style {
90    fn default() -> Self {
91        Self {
92            ban_em_dash: true,
93            ban_ai_attribution: true,
94            terse: true,
95            max_detail_chars: 320,
96            max_summary_chars: 200,
97            max_body_chars: 900,
98            max_title_chars: 90,
99        }
100    }
101}
102
103impl Style {
104    /// Style rules only, no length budgets. Used for text spar composed itself
105    /// and has already sized.
106    pub fn permissive() -> Self {
107        Self {
108            terse: false,
109            ..Self::default()
110        }
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Style gate
116// ---------------------------------------------------------------------------
117
118/// Remove banned style artifacts. Idempotent: scrubbing scrubbed text is a
119/// no-op, which matters because text passes through here more than once.
120pub fn scrub(text: &str, style: &Style) -> String {
121    if text.is_empty() {
122        return String::new();
123    }
124    let mut out = text.to_string();
125
126    if style.ban_ai_attribution {
127        out = ATTRIBUTION_LINE.replace_all(&out, "").into_owned();
128        out = ATTRIBUTION_INLINE.replace_all(&out, "").into_owned();
129        out = out.replace('\u{1F916}', "");
130    }
131
132    if style.ban_em_dash {
133        // "a - b" becomes "a, b". Bounded to spaces and tabs so a dash at the
134        // end of a line joins two lines with a comma instead of swallowing the
135        // paragraph break after it.
136        out = DASH_RUN.replace_all(&out, ", ").into_owned();
137    }
138
139    out = TRAILING_SPACE.replace_all(&out, "").into_owned();
140    out = BLANK_RUN.replace_all(&out, "\n\n").into_owned();
141    out.trim().to_string()
142}
143
144/// Anything the scrub should have caught. Used as a post-check, so that a
145/// pattern the scrub cannot fix becomes a loud failure rather than a leak.
146pub fn violations(text: &str, style: &Style) -> Vec<String> {
147    let mut bad = Vec::new();
148    if style.ban_em_dash && ANY_DASH.is_match(text) {
149        bad.push("em/en dash present".to_string());
150    }
151    if style.ban_ai_attribution
152        && (ATTRIBUTION_LINE.is_match(text) || ATTRIBUTION_INLINE.is_match(text))
153    {
154        bad.push("AI attribution present".to_string());
155    }
156    bad
157}
158
159// ---------------------------------------------------------------------------
160// Concision gate
161// ---------------------------------------------------------------------------
162
163/// Collapse to a single line of single-spaced words.
164///
165/// For a field that is displayed inline, such as a finding title or a one-line
166/// verdict. A model that returns a paragraph there would otherwise break the
167/// layout of everything around it.
168pub fn one_line(text: &str) -> String {
169    text.split_whitespace().collect::<Vec<_>>().join(" ")
170}
171
172/// Truncate to `max` characters, preferring a sentence boundary.
173///
174/// Cutting mid-sentence and marking it with an ellipsis is a last resort: a
175/// clipped finding still has to be actionable, and the first sentence of a
176/// review comment almost always is.
177pub fn clip(text: &str, max: usize) -> String {
178    let trimmed = text.trim();
179    if max == 0 {
180        return trimmed.to_string();
181    }
182    let chars: Vec<char> = trimmed.chars().collect();
183    if chars.len() <= max {
184        return trimmed.to_string();
185    }
186
187    let window = &chars[..max];
188
189    // The last sentence end inside the budget, if it keeps enough of the text
190    // to still be worth reading.
191    let mut sentence_end = None;
192    for (i, c) in window.iter().enumerate() {
193        // Look at the real next character, not `window`'s. A period landing on
194        // the last budget character has text after it; treating the end of the
195        // window as the end of a sentence cuts mid-path with no ellipsis, so
196        // "src/repo.rs:412" is silently served as "src/repo." and reads as
197        // finished prose.
198        if matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace()) {
199            sentence_end = Some(i + 1);
200        }
201    }
202    if let Some(cut) = sentence_end {
203        if cut * 2 >= max {
204            return window[..cut]
205                .iter()
206                .collect::<String>()
207                .trim_end()
208                .to_string();
209        }
210    }
211
212    // Otherwise the last word boundary, marked so the reader knows there is
213    // more where this came from. The mark is inside the budget, never added to
214    // it: a caller that asked for at most N characters gets at most N.
215    const MARK: &str = "...";
216    if max <= MARK.len() {
217        return window.iter().collect::<String>().trim_end().to_string();
218    }
219    let budget = max - MARK.len();
220    let mut end = budget;
221    while end > 0 && !window[end - 1].is_whitespace() {
222        end -= 1;
223    }
224    if end == 0 {
225        end = budget;
226    }
227    let mut out: String = window[..end]
228        .iter()
229        .collect::<String>()
230        .trim_end()
231        .to_string();
232    out.push_str(MARK);
233    out
234}
235
236/// Drop a heading whose section holds nothing, and the bare "## Summary" style
237/// heading that only announces the body underneath it.
238pub fn strip_empty_sections(text: &str) -> String {
239    let lines: Vec<&str> = text.lines().collect();
240    let mut keep: Vec<&str> = Vec::with_capacity(lines.len());
241
242    let mut i = 0;
243    while i < lines.len() {
244        let line = lines[i];
245        if HEADING.is_match(line) {
246            // Everything up to the next heading is this section's body.
247            let mut j = i + 1;
248            while j < lines.len() && !HEADING.is_match(lines[j]) {
249                j += 1;
250            }
251            let body_is_empty = lines[i + 1..j].iter().all(|l| l.trim().is_empty());
252            let only_heading = lines.iter().filter(|l| HEADING.is_match(l)).count() == 1;
253
254            if body_is_empty {
255                i = j; // drop the heading and the blank lines under it
256                continue;
257            }
258            if only_heading && NOISE_HEADING.is_match(line) {
259                i += 1; // drop the label, keep the body
260                continue;
261            }
262        }
263        keep.push(line);
264        i += 1;
265    }
266
267    let joined = keep.join("\n");
268    BLANK_RUN.replace_all(&joined, "\n\n").trim().to_string()
269}
270
271/// The full outbound treatment for a block of model prose: strip structural
272/// noise, then clip to a budget.
273pub fn tighten(text: &str, max: usize, style: &Style) -> String {
274    if !style.terse {
275        return text.trim().to_string();
276    }
277    clip(&strip_empty_sections(text), max)
278}
279
280/// A finding title, issue title, or PR title: always one line, always short.
281pub fn title(text: &str, style: &Style) -> String {
282    let flat = one_line(text);
283    if style.terse {
284        clip(&flat, style.max_title_chars)
285    } else {
286        flat
287    }
288}
289
290/// A one-sentence verdict or disposition reason.
291pub fn summary(text: &str, style: &Style) -> String {
292    let flat = one_line(text);
293    if style.terse {
294        clip(&flat, style.max_summary_chars)
295    } else {
296        flat
297    }
298}
299
300/// A finding's explanation, as it appears in the PR thread. Kept on one line so
301/// a bullet stays a bullet.
302pub fn detail(text: &str, style: &Style) -> String {
303    let flat = one_line(text);
304    if style.terse {
305        clip(&flat, style.max_detail_chars)
306    } else {
307        flat
308    }
309}
310
311/// An issue or PR body. Multi-line is fine here; bloat is not.
312pub fn body(text: &str, style: &Style) -> String {
313    tighten(text, style.max_body_chars, style)
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319
320    fn s() -> Style {
321        Style::default()
322    }
323
324    // -- style gate ------------------------------------------------------
325
326    #[test]
327    fn em_dash_removed() {
328        let out = scrub(
329            "Fix the parser, it was broken \u{2014} badly \u{2014} on empty input.",
330            &s(),
331        );
332        assert!(!out.contains('\u{2014}'));
333        assert!(violations(&out, &s()).is_empty());
334    }
335
336    #[test]
337    fn en_dash_removed() {
338        assert!(!scrub("range 1 \u{2013} 5", &s()).contains('\u{2013}'));
339    }
340
341    #[test]
342    fn horizontal_bar_removed() {
343        assert!(violations(&scrub("a \u{2015} b", &s()), &s()).is_empty());
344    }
345
346    #[test]
347    fn coauthor_trailer_stripped() {
348        let out = scrub(
349            "Add retry logic\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n",
350            &s(),
351        );
352        assert!(!out.contains("Co-Authored-By"));
353        assert!(out.contains("Add retry logic"));
354    }
355
356    #[test]
357    fn generated_with_footer_stripped() {
358        let out = scrub(
359            "Fix bug\n\n\u{1F916} Generated with [Claude Code](https://claude.com)\n",
360            &s(),
361        );
362        assert!(violations(&out, &s()).is_empty(), "{out}");
363        assert!(out.contains("Fix bug"));
364    }
365
366    #[test]
367    fn inline_attribution_stripped() {
368        let out = scrub("This patch was written by Claude to fix the leak.", &s());
369        assert!(violations(&out, &s()).is_empty(), "{out}");
370    }
371
372    #[test]
373    fn scrub_is_idempotent() {
374        let once = scrub("A \u{2014} B\n\nCo-Authored-By: Codex <x@y.z>", &s());
375        assert_eq!(once, scrub(&once, &s()));
376    }
377
378    #[test]
379    fn violations_detected_before_scrub() {
380        assert!(!violations("a \u{2014} b", &s()).is_empty());
381        assert!(!violations("Co-Authored-By: Claude <a@b.c>", &s()).is_empty());
382    }
383
384    #[test]
385    fn legitimate_prose_survives() {
386        let out = scrub("Refactor the AI-facing endpoint handler for clarity.", &s());
387        assert!(out.contains("endpoint handler"), "{out}");
388    }
389
390    #[test]
391    fn disabled_rules_are_respected() {
392        let off = Style {
393            ban_em_dash: false,
394            ban_ai_attribution: false,
395            ..s()
396        };
397        let text = "a \u{2014} b\nCo-Authored-By: Claude <x@y.z>";
398        assert!(scrub(text, &off).contains('\u{2014}'));
399        assert!(violations(text, &off).is_empty());
400    }
401
402    #[test]
403    fn dash_at_end_of_line_does_not_swallow_the_paragraph_break() {
404        let out = scrub("first line \u{2014}\n\nsecond paragraph", &s());
405        assert!(out.contains("\n\n"), "{out:?}");
406    }
407
408    #[test]
409    fn empty_input_is_empty_output() {
410        assert_eq!("", scrub("", &s()));
411    }
412
413    // -- concision gate --------------------------------------------------
414
415    #[test]
416    fn one_line_flattens() {
417        assert_eq!("a b c", one_line("  a\n\n b\t c  "));
418    }
419
420    #[test]
421    fn clip_leaves_short_text_alone() {
422        assert_eq!("short", clip("short", 40));
423    }
424
425    #[test]
426    fn clip_prefers_a_sentence_boundary() {
427        let text = "The loop never terminates. It also leaks a file descriptor on every pass.";
428        assert_eq!("The loop never terminates.", clip(text, 40));
429    }
430
431    #[test]
432    fn clip_falls_back_to_a_word_boundary() {
433        let out = clip("supercalifragilistic wording that runs on and on", 25);
434        assert!(out.ends_with("..."), "{out}");
435        assert!(out.chars().count() <= 25, "{out}");
436        assert!(!out.contains("wording that runs"), "{out}");
437    }
438
439    #[test]
440    fn clip_never_exceeds_the_budget() {
441        for max in 1..60 {
442            let out = clip("one two three four five six seven eight nine ten.", max);
443            assert!(out.chars().count() <= max, "max={max} out={out:?}");
444        }
445    }
446
447    #[test]
448    fn clip_handles_multibyte_text() {
449        let out = clip(&"\u{1f600}".repeat(50), 10);
450        assert!(out.chars().count() <= 10, "{out}");
451    }
452
453    /// The sentence-end scan used to look at the last character of the *budget*
454    /// rather than of the *text*, so a period landing exactly on the boundary
455    /// read as the end of a sentence. The result came back with no ellipsis, so
456    /// a truncated file path looked like finished prose.
457    #[test]
458    fn a_period_on_the_budget_boundary_is_not_a_sentence_end() {
459        assert_ne!(
460            "Version 1.",
461            clip("Version 1.4 of the parser mishandles input", 10)
462        );
463        assert_ne!(
464            "Panic in src/style.",
465            clip("Panic in src/style.rs when the budget lands mid word", 19)
466        );
467    }
468
469    #[test]
470    fn an_unmarked_clip_really_did_end_a_sentence() {
471        // The only way to come back without an ellipsis is to stop where the
472        // author stopped.
473        for max in 4..80 {
474            let text = "First sentence here. Second one follows it. Third trails off";
475            let out = clip(text, max);
476            if out.len() < text.len() && !out.ends_with("...") {
477                assert!(
478                    out.ends_with('.') || out.ends_with('!') || out.ends_with('?'),
479                    "max={max} out={out:?}"
480                );
481                let next = text[out.len()..].chars().next();
482                assert!(
483                    next.is_none_or(|c| c.is_whitespace()),
484                    "max={max} cut mid-token before {next:?}: {out:?}"
485                );
486            }
487        }
488    }
489
490    #[test]
491    fn clip_ignores_a_decimal_point_as_a_sentence_end() {
492        let text = "Version 1.4 of the parser mishandles empty input badly and loops.";
493        assert_ne!("Version 1.", clip(text, 30));
494    }
495
496    #[test]
497    fn empty_sections_are_dropped() {
498        let out = strip_empty_sections("## Context\n\n## Proposal\n\nDo the thing.\n");
499        assert!(!out.contains("Context"), "{out}");
500        assert!(out.contains("Do the thing."), "{out}");
501    }
502
503    #[test]
504    fn a_lone_label_heading_is_dropped() {
505        assert_eq!(
506            "The retry never fires.",
507            strip_empty_sections("## Summary\n\nThe retry never fires.")
508        );
509    }
510
511    #[test]
512    fn real_headings_survive_when_there_are_several() {
513        let text = "## Summary\n\nA thing.\n\n## Repro\n\nRun it.";
514        let out = strip_empty_sections(text);
515        assert!(
516            out.contains("## Summary") && out.contains("## Repro"),
517            "{out}"
518        );
519    }
520
521    #[test]
522    fn terse_off_leaves_length_alone() {
523        let loose = Style {
524            terse: false,
525            ..s()
526        };
527        let long = "word ".repeat(400);
528        assert_eq!(long.trim(), detail(&long, &loose));
529    }
530
531    #[test]
532    fn detail_is_capped_and_single_line() {
533        let out = detail(
534            &format!("first line\nsecond line\n{}", "filler ".repeat(200)),
535            &s(),
536        );
537        assert!(!out.contains('\n'));
538        assert!(out.chars().count() <= s().max_detail_chars);
539    }
540
541    #[test]
542    fn title_is_capped_and_single_line() {
543        let out = title(
544            "a very\nlong\ttitle that keeps going ".repeat(20).as_str(),
545            &s(),
546        );
547        assert!(!out.contains('\n'));
548        assert!(out.chars().count() <= s().max_title_chars);
549    }
550
551    #[test]
552    fn body_keeps_structure_but_bounds_length() {
553        let text = format!(
554            "## Summary\n\nreal content here.\n\n{}",
555            "more prose. ".repeat(300)
556        );
557        let out = body(&text, &s());
558        assert!(
559            out.chars().count() <= s().max_body_chars,
560            "{}",
561            out.chars().count()
562        );
563        assert!(out.contains("real content here"), "{out}");
564    }
565}