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    /// How much of its own working spar narrates into a pull request thread.
88    pub pr_comments: crate::config::PrComments,
89}
90
91impl Default for Style {
92    fn default() -> Self {
93        Self {
94            ban_em_dash: true,
95            ban_ai_attribution: true,
96            terse: true,
97            max_detail_chars: 320,
98            max_summary_chars: 200,
99            max_body_chars: 900,
100            max_title_chars: 90,
101            pr_comments: crate::config::PrComments::Outcome,
102        }
103    }
104}
105
106impl Style {
107    /// Style rules only, no length budgets. Used for text spar composed itself
108    /// and has already sized.
109    pub fn permissive() -> Self {
110        Self {
111            terse: false,
112            ..Self::default()
113        }
114    }
115}
116
117// ---------------------------------------------------------------------------
118// Style gate
119// ---------------------------------------------------------------------------
120
121/// Remove banned style artifacts. Idempotent: scrubbing scrubbed text is a
122/// no-op, which matters because text passes through here more than once.
123pub fn scrub(text: &str, style: &Style) -> String {
124    if text.is_empty() {
125        return String::new();
126    }
127    let mut out = text.to_string();
128
129    if style.ban_ai_attribution {
130        out = ATTRIBUTION_LINE.replace_all(&out, "").into_owned();
131        out = ATTRIBUTION_INLINE.replace_all(&out, "").into_owned();
132        out = out.replace('\u{1F916}', "");
133    }
134
135    if style.ban_em_dash {
136        // "a - b" becomes "a, b". Bounded to spaces and tabs so a dash at the
137        // end of a line joins two lines with a comma instead of swallowing the
138        // paragraph break after it.
139        out = DASH_RUN.replace_all(&out, ", ").into_owned();
140    }
141
142    out = TRAILING_SPACE.replace_all(&out, "").into_owned();
143    out = BLANK_RUN.replace_all(&out, "\n\n").into_owned();
144    out.trim().to_string()
145}
146
147/// Anything the scrub should have caught. Used as a post-check, so that a
148/// pattern the scrub cannot fix becomes a loud failure rather than a leak.
149pub fn violations(text: &str, style: &Style) -> Vec<String> {
150    let mut bad = Vec::new();
151    if style.ban_em_dash && ANY_DASH.is_match(text) {
152        bad.push("em/en dash present".to_string());
153    }
154    if style.ban_ai_attribution
155        && (ATTRIBUTION_LINE.is_match(text) || ATTRIBUTION_INLINE.is_match(text))
156    {
157        bad.push("AI attribution present".to_string());
158    }
159    bad
160}
161
162// ---------------------------------------------------------------------------
163// Concision gate
164// ---------------------------------------------------------------------------
165
166/// Collapse to a single line of single-spaced words.
167///
168/// For a field that is displayed inline, such as a finding title or a one-line
169/// verdict. A model that returns a paragraph there would otherwise break the
170/// layout of everything around it.
171pub fn one_line(text: &str) -> String {
172    text.split_whitespace().collect::<Vec<_>>().join(" ")
173}
174
175/// Truncate to `max` characters, preferring a sentence boundary.
176///
177/// Cutting mid-sentence and marking it with an ellipsis is a last resort: a
178/// clipped finding still has to be actionable, and the first sentence of a
179/// review comment almost always is.
180pub fn clip(text: &str, max: usize) -> String {
181    let trimmed = text.trim();
182    if max == 0 {
183        return trimmed.to_string();
184    }
185    let chars: Vec<char> = trimmed.chars().collect();
186    if chars.len() <= max {
187        return trimmed.to_string();
188    }
189
190    let window = &chars[..max];
191
192    // The last sentence end inside the budget, if it keeps enough of the text
193    // to still be worth reading.
194    let mut sentence_end = None;
195    for (i, c) in window.iter().enumerate() {
196        // Look at the real next character, not `window`'s. A period landing on
197        // the last budget character has text after it; treating the end of the
198        // window as the end of a sentence cuts mid-path with no ellipsis, so
199        // "src/repo.rs:412" is silently served as "src/repo." and reads as
200        // finished prose.
201        if matches!(c, '.' | '!' | '?') && chars.get(i + 1).is_none_or(|n| n.is_whitespace()) {
202            sentence_end = Some(i + 1);
203        }
204    }
205    if let Some(cut) = sentence_end {
206        if cut * 2 >= max {
207            return window[..cut]
208                .iter()
209                .collect::<String>()
210                .trim_end()
211                .to_string();
212        }
213    }
214
215    // Otherwise the last word boundary, marked so the reader knows there is
216    // more where this came from. The mark is inside the budget, never added to
217    // it: a caller that asked for at most N characters gets at most N.
218    const MARK: &str = "...";
219    if max <= MARK.len() {
220        return window.iter().collect::<String>().trim_end().to_string();
221    }
222    let budget = max - MARK.len();
223    let mut end = budget;
224    while end > 0 && !window[end - 1].is_whitespace() {
225        end -= 1;
226    }
227    if end == 0 {
228        end = budget;
229    }
230    let mut out: String = window[..end]
231        .iter()
232        .collect::<String>()
233        .trim_end()
234        .to_string();
235    out.push_str(MARK);
236    out
237}
238
239/// Drop a heading whose section holds nothing, and the bare "## Summary" style
240/// heading that only announces the body underneath it.
241pub fn strip_empty_sections(text: &str) -> String {
242    let lines: Vec<&str> = text.lines().collect();
243    let mut keep: Vec<&str> = Vec::with_capacity(lines.len());
244
245    let mut i = 0;
246    while i < lines.len() {
247        let line = lines[i];
248        if HEADING.is_match(line) {
249            // Everything up to the next heading is this section's body.
250            let mut j = i + 1;
251            while j < lines.len() && !HEADING.is_match(lines[j]) {
252                j += 1;
253            }
254            let body_is_empty = lines[i + 1..j].iter().all(|l| l.trim().is_empty());
255            let only_heading = lines.iter().filter(|l| HEADING.is_match(l)).count() == 1;
256
257            if body_is_empty {
258                i = j; // drop the heading and the blank lines under it
259                continue;
260            }
261            if only_heading && NOISE_HEADING.is_match(line) {
262                i += 1; // drop the label, keep the body
263                continue;
264            }
265        }
266        keep.push(line);
267        i += 1;
268    }
269
270    let joined = keep.join("\n");
271    BLANK_RUN.replace_all(&joined, "\n\n").trim().to_string()
272}
273
274/// The full outbound treatment for a block of model prose: strip structural
275/// noise, then clip to a budget.
276pub fn tighten(text: &str, max: usize, style: &Style) -> String {
277    if !style.terse {
278        return text.trim().to_string();
279    }
280    clip(&strip_empty_sections(text), max)
281}
282
283/// A finding title, issue title, or PR title: always one line, always short.
284pub fn title(text: &str, style: &Style) -> String {
285    let flat = one_line(text);
286    if style.terse {
287        clip(&flat, style.max_title_chars)
288    } else {
289        flat
290    }
291}
292
293/// Capitalise the first letter, so a model's fragment reads as a sentence when
294/// spar sets it after one of its own.
295pub fn sentence(text: &str, style: &Style) -> String {
296    let one = summary(text, style);
297    let mut chars = one.chars();
298    match chars.next() {
299        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
300        None => one,
301    }
302}
303
304/// A one-sentence verdict or disposition reason.
305pub fn summary(text: &str, style: &Style) -> String {
306    let flat = one_line(text);
307    if style.terse {
308        clip(&flat, style.max_summary_chars)
309    } else {
310        flat
311    }
312}
313
314/// A finding's explanation, as it appears in the PR thread. Kept on one line so
315/// a bullet stays a bullet.
316pub fn detail(text: &str, style: &Style) -> String {
317    let flat = one_line(text);
318    if style.terse {
319        clip(&flat, style.max_detail_chars)
320    } else {
321        flat
322    }
323}
324
325/// An issue or PR body. Multi-line is fine here; bloat is not.
326pub fn body(text: &str, style: &Style) -> String {
327    tighten(text, style.max_body_chars, style)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn s() -> Style {
335        Style::default()
336    }
337
338    // -- style gate ------------------------------------------------------
339
340    #[test]
341    fn em_dash_removed() {
342        let out = scrub(
343            "Fix the parser, it was broken \u{2014} badly \u{2014} on empty input.",
344            &s(),
345        );
346        assert!(!out.contains('\u{2014}'));
347        assert!(violations(&out, &s()).is_empty());
348    }
349
350    #[test]
351    fn en_dash_removed() {
352        assert!(!scrub("range 1 \u{2013} 5", &s()).contains('\u{2013}'));
353    }
354
355    #[test]
356    fn horizontal_bar_removed() {
357        assert!(violations(&scrub("a \u{2015} b", &s()), &s()).is_empty());
358    }
359
360    #[test]
361    fn coauthor_trailer_stripped() {
362        let out = scrub(
363            "Add retry logic\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n",
364            &s(),
365        );
366        assert!(!out.contains("Co-Authored-By"));
367        assert!(out.contains("Add retry logic"));
368    }
369
370    #[test]
371    fn generated_with_footer_stripped() {
372        let out = scrub(
373            "Fix bug\n\n\u{1F916} Generated with [Claude Code](https://claude.com)\n",
374            &s(),
375        );
376        assert!(violations(&out, &s()).is_empty(), "{out}");
377        assert!(out.contains("Fix bug"));
378    }
379
380    #[test]
381    fn inline_attribution_stripped() {
382        let out = scrub("This patch was written by Claude to fix the leak.", &s());
383        assert!(violations(&out, &s()).is_empty(), "{out}");
384    }
385
386    #[test]
387    fn scrub_is_idempotent() {
388        let once = scrub("A \u{2014} B\n\nCo-Authored-By: Codex <x@y.z>", &s());
389        assert_eq!(once, scrub(&once, &s()));
390    }
391
392    #[test]
393    fn violations_detected_before_scrub() {
394        assert!(!violations("a \u{2014} b", &s()).is_empty());
395        assert!(!violations("Co-Authored-By: Claude <a@b.c>", &s()).is_empty());
396    }
397
398    #[test]
399    fn legitimate_prose_survives() {
400        let out = scrub("Refactor the AI-facing endpoint handler for clarity.", &s());
401        assert!(out.contains("endpoint handler"), "{out}");
402    }
403
404    #[test]
405    fn disabled_rules_are_respected() {
406        let off = Style {
407            ban_em_dash: false,
408            ban_ai_attribution: false,
409            ..s()
410        };
411        let text = "a \u{2014} b\nCo-Authored-By: Claude <x@y.z>";
412        assert!(scrub(text, &off).contains('\u{2014}'));
413        assert!(violations(text, &off).is_empty());
414    }
415
416    #[test]
417    fn dash_at_end_of_line_does_not_swallow_the_paragraph_break() {
418        let out = scrub("first line \u{2014}\n\nsecond paragraph", &s());
419        assert!(out.contains("\n\n"), "{out:?}");
420    }
421
422    #[test]
423    fn empty_input_is_empty_output() {
424        assert_eq!("", scrub("", &s()));
425    }
426
427    // -- concision gate --------------------------------------------------
428
429    #[test]
430    fn one_line_flattens() {
431        assert_eq!("a b c", one_line("  a\n\n b\t c  "));
432    }
433
434    #[test]
435    fn clip_leaves_short_text_alone() {
436        assert_eq!("short", clip("short", 40));
437    }
438
439    #[test]
440    fn clip_prefers_a_sentence_boundary() {
441        let text = "The loop never terminates. It also leaks a file descriptor on every pass.";
442        assert_eq!("The loop never terminates.", clip(text, 40));
443    }
444
445    #[test]
446    fn clip_falls_back_to_a_word_boundary() {
447        let out = clip("supercalifragilistic wording that runs on and on", 25);
448        assert!(out.ends_with("..."), "{out}");
449        assert!(out.chars().count() <= 25, "{out}");
450        assert!(!out.contains("wording that runs"), "{out}");
451    }
452
453    #[test]
454    fn clip_never_exceeds_the_budget() {
455        for max in 1..60 {
456            let out = clip("one two three four five six seven eight nine ten.", max);
457            assert!(out.chars().count() <= max, "max={max} out={out:?}");
458        }
459    }
460
461    #[test]
462    fn clip_handles_multibyte_text() {
463        let out = clip(&"\u{1f600}".repeat(50), 10);
464        assert!(out.chars().count() <= 10, "{out}");
465    }
466
467    /// The sentence-end scan used to look at the last character of the *budget*
468    /// rather than of the *text*, so a period landing exactly on the boundary
469    /// read as the end of a sentence. The result came back with no ellipsis, so
470    /// a truncated file path looked like finished prose.
471    #[test]
472    fn a_period_on_the_budget_boundary_is_not_a_sentence_end() {
473        assert_ne!(
474            "Version 1.",
475            clip("Version 1.4 of the parser mishandles input", 10)
476        );
477        assert_ne!(
478            "Panic in src/style.",
479            clip("Panic in src/style.rs when the budget lands mid word", 19)
480        );
481    }
482
483    #[test]
484    fn an_unmarked_clip_really_did_end_a_sentence() {
485        // The only way to come back without an ellipsis is to stop where the
486        // author stopped.
487        for max in 4..80 {
488            let text = "First sentence here. Second one follows it. Third trails off";
489            let out = clip(text, max);
490            if out.len() < text.len() && !out.ends_with("...") {
491                assert!(
492                    out.ends_with('.') || out.ends_with('!') || out.ends_with('?'),
493                    "max={max} out={out:?}"
494                );
495                let next = text[out.len()..].chars().next();
496                assert!(
497                    next.is_none_or(|c| c.is_whitespace()),
498                    "max={max} cut mid-token before {next:?}: {out:?}"
499                );
500            }
501        }
502    }
503
504    #[test]
505    fn clip_ignores_a_decimal_point_as_a_sentence_end() {
506        let text = "Version 1.4 of the parser mishandles empty input badly and loops.";
507        assert_ne!("Version 1.", clip(text, 30));
508    }
509
510    #[test]
511    fn empty_sections_are_dropped() {
512        let out = strip_empty_sections("## Context\n\n## Proposal\n\nDo the thing.\n");
513        assert!(!out.contains("Context"), "{out}");
514        assert!(out.contains("Do the thing."), "{out}");
515    }
516
517    #[test]
518    fn a_lone_label_heading_is_dropped() {
519        assert_eq!(
520            "The retry never fires.",
521            strip_empty_sections("## Summary\n\nThe retry never fires.")
522        );
523    }
524
525    #[test]
526    fn real_headings_survive_when_there_are_several() {
527        let text = "## Summary\n\nA thing.\n\n## Repro\n\nRun it.";
528        let out = strip_empty_sections(text);
529        assert!(
530            out.contains("## Summary") && out.contains("## Repro"),
531            "{out}"
532        );
533    }
534
535    #[test]
536    fn terse_off_leaves_length_alone() {
537        let loose = Style {
538            terse: false,
539            ..s()
540        };
541        let long = "word ".repeat(400);
542        assert_eq!(long.trim(), detail(&long, &loose));
543    }
544
545    #[test]
546    fn detail_is_capped_and_single_line() {
547        let out = detail(
548            &format!("first line\nsecond line\n{}", "filler ".repeat(200)),
549            &s(),
550        );
551        assert!(!out.contains('\n'));
552        assert!(out.chars().count() <= s().max_detail_chars);
553    }
554
555    #[test]
556    fn title_is_capped_and_single_line() {
557        let out = title(
558            "a very\nlong\ttitle that keeps going ".repeat(20).as_str(),
559            &s(),
560        );
561        assert!(!out.contains('\n'));
562        assert!(out.chars().count() <= s().max_title_chars);
563    }
564
565    #[test]
566    fn body_keeps_structure_but_bounds_length() {
567        let text = format!(
568            "## Summary\n\nreal content here.\n\n{}",
569            "more prose. ".repeat(300)
570        );
571        let out = body(&text, &s());
572        assert!(
573            out.chars().count() <= s().max_body_chars,
574            "{}",
575            out.chars().count()
576        );
577        assert!(out.contains("real content here"), "{out}");
578    }
579}
580
581#[cfg(test)]
582mod sentence_tests {
583    use super::*;
584
585    #[test]
586    fn a_fragment_reads_as_a_sentence() {
587        assert_eq!(
588            "The caller already validates it.",
589            sentence("the caller already validates it.", &Style::default())
590        );
591    }
592
593    #[test]
594    fn an_already_capitalised_one_is_untouched() {
595        assert_eq!(
596            "Already fine.",
597            sentence("Already fine.", &Style::default())
598        );
599    }
600
601    #[test]
602    fn empty_stays_empty_rather_than_panicking() {
603        assert_eq!("", sentence("   ", &Style::default()));
604    }
605
606    #[test]
607    fn a_multibyte_first_character_does_not_panic() {
608        assert_eq!("Ärger", sentence("ärger", &Style::default()));
609    }
610}