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//! **Shape.** The reader of a PR is a human with other work. What made a thread
9//! unreadable was never the length of the findings, it was spar narrating
10//! itself: which agent spoke, which round it was, counts of things listed on the
11//! next line. So spar composes every comment itself from structured fields, and
12//! that is where brevity comes from.
13//!
14//! The length budgets below are safety valves, not editors. They are sized so
15//! that real content is never touched, and when one does fire it completes the
16//! sentence in progress rather than stopping mid-thought. Cutting substance was
17//! a mistake worth naming: a reader who cannot act on a finding has been given
18//! nothing, and the characters saved bought nothing. Brevity is asked for in the
19//! prompts, which is free, and enforced only on shape.
20
21use std::sync::LazyLock;
22
23use regex::Regex;
24
25/// Every dash a model reaches for instead of a hyphen.
26///
27/// U+2012 to U+2015 are the figure, en, em and horizontal bar. U+2011 is the
28/// non-breaking hyphen and U+2E3A and U+2E3B the two and three em dashes: all
29/// three render as a dash and all three were outside the class.
30const DASHES: &str = r"[\x{2011}-\x{2015}\x{2E3A}\x{2E3B}]";
31
32/// The tools and vendors a trailer names.
33///
34/// A maintained list rather than a guess: `co-authored-by:` and
35/// `signed-off-by:` are ordinary trailers a person writes about another
36/// person, and removing one of those would be worse than leaving an
37/// attribution in.
38const VENDORS: &str = concat!(
39    r"claude|anthropic|codex|openai|chatgpt|gpt[-\s]?[0-9o]|copilot|cursor",
40    r"|gemini|aider|devin|llama|an? ai\b|\bais?\b",
41);
42
43/// A trailer line naming a tool or a vendor.
44///
45/// Line anchored, because that is what a trailer is. Prose about trailers is
46/// left alone, which is why the repository's own issues can discuss them.
47static ATTRIBUTION_LINE: LazyLock<Regex> = LazyLock::new(|| {
48    Regex::new(&format!(
49        concat!(
50            r"(?im)^\s*(?:[>*-]\s*)?(?:",
51            r"(?:co[-\s]?authored|signed[-\s]?off|assisted|generated|authored|written|created)",
52            r"[-\s]?by\s*:.*(?:{vendors}).*",
53            r"|\x{{1F916}}?\s*generated (?:with|by) .*",
54            r")\s*$",
55        ),
56        vendors = VENDORS
57    ))
58    .expect("attribution line pattern")
59});
60
61/// The same claim written into a sentence.
62///
63/// The vendor may be a few words along, inside a markdown link, or in
64/// possessive form: "Generated with [Claude Code](https://claude.com)" and
65/// "Generated by Anthropic's Claude" are both this.
66static ATTRIBUTION_INLINE: LazyLock<Regex> = LazyLock::new(|| {
67    Regex::new(&format!(
68        concat!(
69            r"(?i)\s*\x{{1F916}}?\s*\b(?:",
70            r"(?:generated|written|authored|created|made|built)\s+(?:with|by)",
71            r"|with the help of|using)\s+",
72            r"(?:the\s+help\s+of\s+)?[\[\(*_\x22'`]*",
73            r"[A-Za-z0-9.'\x2D\s]{{0,24}}?(?:{vendors})",
74            r"[A-Za-z0-9 ]{{0,16}}?[\]\)*_`]*",
75            r"(?:\([^)\n]{{0,160}}\))?",
76            r"|(?i)\bas an ai\b",
77        ),
78        vendors = VENDORS
79    ))
80    .expect("attribution inline pattern")
81});
82
83/// What the gate looks for and the scrub cannot clean.
84///
85/// A vendor's no-reply address is attribution in every case that matters and
86/// there is no sentence to leave behind once it is removed, so it is a hard
87/// error rather than an edit. GitHub's own no-reply domain is deliberately not
88/// here: that is how a real person is co-authored. The robot emoji is here too:
89/// the scrub deletes it, so seeing one afterwards means something went wrong.
90static ATTRIBUTION_RESIDUE: LazyLock<Regex> = LazyLock::new(|| {
91    Regex::new(concat!(
92        r"(?i)noreply@(?:anthropic|openai)\.com",
93        r"|\x{1F916}",
94    ))
95    .expect("attribution residue pattern")
96});
97
98static DASH_RUN: LazyLock<Regex> =
99    LazyLock::new(|| Regex::new(&format!(r"[ \t]*{DASHES}[ \t]*")).expect("dash pattern"));
100
101static ANY_DASH: LazyLock<Regex> = LazyLock::new(|| Regex::new(DASHES).expect("dash class"));
102
103static TRAILING_SPACE: LazyLock<Regex> =
104    LazyLock::new(|| Regex::new(r"(?m)[ \t]+$").expect("trailing space pattern"));
105
106static BLANK_RUN: LazyLock<Regex> =
107    LazyLock::new(|| Regex::new(r"\n{3,}").expect("blank run pattern"));
108
109static HEADING: LazyLock<Regex> =
110    LazyLock::new(|| Regex::new(r"^\s{0,3}#{1,6}\s+\S").expect("heading pattern"));
111
112/// Headings that only announce that a body follows. Dropping them costs the
113/// reader nothing and saves them a line.
114static NOISE_HEADING: LazyLock<Regex> = LazyLock::new(|| {
115    Regex::new(
116        r"(?i)^\s{0,3}#{1,6}\s*(summary|description|overview|context|details?|background)\s*:?\s*$",
117    )
118    .expect("noise heading pattern")
119});
120
121/// Everything the two gates need to know. Mirrors the `[style]` config block.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Style {
124    pub ban_em_dash: bool,
125    pub ban_ai_attribution: bool,
126    /// Enforce the length budgets below. Off means model prose passes through
127    /// at whatever length it arrived at.
128    pub terse: bool,
129    /// A finding's explanatory detail, as shown in the PR thread.
130    pub max_detail_chars: usize,
131    /// A one-line verdict or disposition summary.
132    pub max_summary_chars: usize,
133    /// A PR body.
134    pub max_body_chars: usize,
135    /// A filed issue's body.
136    pub max_issue_body_chars: usize,
137    /// A finding title, issue title, or PR title.
138    pub max_title_chars: usize,
139    /// How much of its own working spar narrates into a pull request thread.
140    pub pr_comments: crate::config::PrComments,
141}
142
143impl Default for Style {
144    fn default() -> Self {
145        Self {
146            ban_em_dash: true,
147            ban_ai_attribution: true,
148            terse: true,
149            max_detail_chars: 6000,
150            max_summary_chars: 2000,
151            max_body_chars: 8000,
152            max_issue_body_chars: 20000,
153            max_title_chars: 140,
154            pr_comments: crate::config::PrComments::Outcome,
155        }
156    }
157}
158
159impl Style {
160    /// Style rules only, no length budgets. Used for text spar composed itself
161    /// and has already sized.
162    pub fn permissive() -> Self {
163        Self {
164            terse: false,
165            ..Self::default()
166        }
167    }
168}
169
170// ---------------------------------------------------------------------------
171// Style gate
172// ---------------------------------------------------------------------------
173
174/// Remove banned style artifacts. Idempotent: scrubbing scrubbed text is a
175/// no-op, which matters because text passes through here more than once.
176pub fn scrub(text: &str, style: &Style) -> String {
177    if text.is_empty() {
178        return String::new();
179    }
180    let mut out = text.to_string();
181
182    if style.ban_ai_attribution {
183        out = ATTRIBUTION_LINE.replace_all(&out, "").into_owned();
184        out = outside_code(&out, |part| {
185            ATTRIBUTION_INLINE.replace_all(part, "").into_owned()
186        });
187        out = out.replace('\u{1F916}', "");
188    }
189
190    if style.ban_em_dash {
191        // "a - b" becomes "a, b". Bounded to spaces and tabs so a dash at the
192        // end of a line joins two lines with a comma instead of swallowing the
193        // paragraph break after it.
194        //
195        // Never inside code. A reproduction that quotes `let s = "Save , Draft"`
196        // is evidence somebody is being asked to act on, and rewriting it makes
197        // a finding about a dash in a string unreadable by removing the dash.
198        out = outside_code(&out, |part| DASH_RUN.replace_all(part, ", ").into_owned());
199    }
200
201    out = TRAILING_SPACE.replace_all(&out, "").into_owned();
202    out = BLANK_RUN.replace_all(&out, "\n\n").into_owned();
203    out.trim().to_string()
204}
205
206/// Anything the scrub should have caught. Used as a post-check, so that a
207/// pattern the scrub cannot fix becomes a loud failure rather than a leak.
208pub fn violations(text: &str, style: &Style) -> Vec<String> {
209    let mut bad = Vec::new();
210    if style.ban_em_dash && ANY_DASH.is_match(&without_code(text)) {
211        bad.push("em/en dash present".to_string());
212    }
213    if style.ban_ai_attribution
214        && (ATTRIBUTION_LINE.is_match(text)
215            || ATTRIBUTION_INLINE.is_match(&without_code(text))
216            || ATTRIBUTION_RESIDUE.is_match(text))
217    {
218        bad.push("AI attribution present".to_string());
219    }
220    bad
221}
222
223/// Apply an edit to everything except code.
224///
225/// Fenced blocks and inline spans are quoted material: a snippet somebody is
226/// being asked to reproduce, or the exact line a finding is about. The scrub
227/// changes what spar wrote, and it did not write those.
228fn outside_code(text: &str, edit: impl Fn(&str) -> String) -> String {
229    let mut out = String::with_capacity(text.len());
230    let mut last = 0;
231    for (start, end) in code_regions(text) {
232        out.push_str(&edit(&text[last..start]));
233        out.push_str(&text[start..end]);
234        last = end;
235    }
236    out.push_str(&edit(&text[last..]));
237    out
238}
239
240/// The same text with every code region blanked, for the gate to read.
241fn without_code(text: &str) -> String {
242    let mut out = String::with_capacity(text.len());
243    let mut last = 0;
244    for (start, end) in code_regions(text) {
245        out.push_str(&text[last..start]);
246        // Keep the newlines so line anchored patterns still see line ends.
247        out.extend(
248            text[start..end]
249                .chars()
250                .map(|c| if c == '\n' { '\n' } else { ' ' }),
251        );
252        last = end;
253    }
254    out.push_str(&text[last..]);
255    out
256}
257
258/// Byte ranges of fenced blocks and inline code spans, in order.
259///
260/// Deliberately close to what GitHub renders rather than to CommonMark in
261/// full: a fence opens at the start of a line, an inline span opens and closes
262/// on a backtick run of the same length, and an unterminated one runs to the
263/// end of the text rather than swallowing everything after it as prose.
264fn code_regions(text: &str) -> Vec<(usize, usize)> {
265    let bytes = text.as_bytes();
266    let mut regions = Vec::new();
267    let mut i = 0;
268    let mut at_line_start = true;
269    while i < bytes.len() {
270        let byte = bytes[i];
271        if byte == b'\n' {
272            at_line_start = true;
273            i += 1;
274            continue;
275        }
276        if byte != b'`' {
277            if !byte.is_ascii_whitespace() {
278                at_line_start = false;
279            }
280            i += 1;
281            continue;
282        }
283        let run = bytes[i..].iter().take_while(|b| **b == b'`').count();
284        if at_line_start && run >= 3 {
285            // A fenced block: everything to the closing fence, or to the end.
286            let after = i + run;
287            let end = find_closing_fence(text, after, run).unwrap_or(text.len());
288            regions.push((i, end));
289            i = end;
290            at_line_start = false;
291            continue;
292        }
293        // An inline span, closed by a run of the same length on the same line.
294        let after = i + run;
295        let end = find_closing_ticks(bytes, after, run).unwrap_or(text.len());
296        regions.push((i, end));
297        i = end;
298        at_line_start = false;
299    }
300    regions
301}
302
303fn find_closing_fence(text: &str, from: usize, run: usize) -> Option<usize> {
304    let fence = "`".repeat(run);
305    let mut at = from;
306    while let Some(found) = text[at..].find(&fence) {
307        let start = at + found;
308        let line_start = text[..start].rfind('\n').map(|n| n + 1).unwrap_or(0);
309        if text[line_start..start].trim().is_empty() {
310            let end = start + fence.len();
311            return Some(
312                text[end..]
313                    .find('\n')
314                    .map(|n| end + n + 1)
315                    .unwrap_or(text.len()),
316            );
317        }
318        at = start + fence.len();
319    }
320    None
321}
322
323fn find_closing_ticks(bytes: &[u8], from: usize, run: usize) -> Option<usize> {
324    let mut i = from;
325    while i < bytes.len() {
326        match bytes[i] {
327            b'\n' => return None,
328            b'`' => {
329                let here = bytes[i..].iter().take_while(|b| **b == b'`').count();
330                if here == run {
331                    return Some(i + here);
332                }
333                i += here;
334            }
335            _ => i += 1,
336        }
337    }
338    None
339}
340
341// ---------------------------------------------------------------------------
342// Concision gate
343// ---------------------------------------------------------------------------
344
345/// Collapse to a single line of single-spaced words.
346///
347/// For a field that is displayed inline, such as a finding title or a one-line
348/// verdict. A model that returns a paragraph there would otherwise break the
349/// layout of everything around it.
350pub fn one_line(text: &str) -> String {
351    text.split_whitespace().collect::<Vec<_>>().join(" ")
352}
353
354/// How far past a budget it is worth going to finish the sentence in progress.
355///
356/// A budget is a target, not a guillotine. Stopping mid-clause costs the reader
357/// the point being made and gains a handful of characters, which is a bad
358/// trade: a real close comment ended "and surviving instances already reconnect
359/// and..." and told nobody anything.
360const OVERSHOOT: usize = 240;
361
362/// Truncate to roughly `max` characters, ending on a complete sentence.
363pub fn clip(text: &str, max: usize) -> String {
364    clip_marked(text, max, "...")
365}
366
367/// The same, without an ellipsis when it does have to cut.
368///
369/// For a title, where a trailing "..." reads as broken rather than as
370/// shortened. Two issues were filed on a real repository with titles ending in
371/// a literal ellipsis, which is how this was found.
372pub fn clip_bare(text: &str, max: usize) -> String {
373    clip_marked(text, max, "")
374}
375
376fn clip_marked(text: &str, max: usize, marker: &str) -> String {
377    let trimmed = text.trim();
378    if max == 0 {
379        return trimmed.to_string();
380    }
381    let chars: Vec<char> = trimmed.chars().collect();
382    if chars.len() <= max {
383        return trimmed.to_string();
384    }
385
386    let ends_sentence = |i: usize| -> bool {
387        matches!(chars[i], '.' | '!' | '?')
388            // Look at the real next character, not the end of some window: a
389            // period landing on the budget has text after it, and treating that
390            // as the end of a sentence cuts a file path in half.
391            && chars.get(i + 1).is_none_or(|n| n.is_whitespace())
392    };
393
394    // The last sentence that ends at or before the budget, if it keeps enough
395    // of the text to be worth reading.
396    let within = (0..max).rfind(|i| ends_sentence(*i));
397    if let Some(cut) = within {
398        if (cut + 1) * 2 >= max {
399            return chars[..=cut]
400                .iter()
401                .collect::<String>()
402                .trim_end()
403                .to_string();
404        }
405    }
406
407    // Otherwise finish the sentence that is in progress, so long as it ends
408    // somewhere reasonable rather than running on forever. Bounded by the
409    // budget as well as by a constant, so a small budget cannot be doubled and
410    // doubled again by one long sentence.
411    let ceiling = (max + OVERSHOOT.min(max)).min(chars.len());
412    if let Some(cut) = (max..ceiling).find(|i| ends_sentence(*i)) {
413        return chars[..=cut]
414            .iter()
415            .collect::<String>()
416            .trim_end()
417            .to_string();
418    }
419
420    // A short complete sentence still beats a long severed one.
421    if let Some(cut) = within {
422        return chars[..=cut]
423            .iter()
424            .collect::<String>()
425            .trim_end()
426            .to_string();
427    }
428
429    // No sentence in sight. Cut at a word boundary, and say so unless the
430    // caller would rather not.
431    let budget = max.saturating_sub(marker.chars().count()).max(1);
432    let mut end = budget.min(chars.len());
433    while end > 0 && !chars[end - 1].is_whitespace() {
434        end -= 1;
435    }
436    if end == 0 {
437        end = budget.min(chars.len());
438    }
439    let mut out: String = chars[..end]
440        .iter()
441        .collect::<String>()
442        .trim_end()
443        .to_string();
444    out.push_str(marker);
445    out
446}
447
448/// Drop a heading whose section holds nothing, and the bare "## Summary" style
449/// heading that only announces the body underneath it.
450pub fn strip_empty_sections(text: &str) -> String {
451    let lines: Vec<&str> = text.lines().collect();
452    let mut keep: Vec<&str> = Vec::with_capacity(lines.len());
453
454    let mut i = 0;
455    while i < lines.len() {
456        let line = lines[i];
457        if HEADING.is_match(line) {
458            // Everything up to the next heading is this section's body.
459            let mut j = i + 1;
460            while j < lines.len() && !HEADING.is_match(lines[j]) {
461                j += 1;
462            }
463            let body_is_empty = lines[i + 1..j].iter().all(|l| l.trim().is_empty());
464            let only_heading = lines.iter().filter(|l| HEADING.is_match(l)).count() == 1;
465
466            if body_is_empty {
467                i = j; // drop the heading and the blank lines under it
468                continue;
469            }
470            if only_heading && NOISE_HEADING.is_match(line) {
471                i += 1; // drop the label, keep the body
472                continue;
473            }
474        }
475        keep.push(line);
476        i += 1;
477    }
478
479    let joined = keep.join("\n");
480    BLANK_RUN.replace_all(&joined, "\n\n").trim().to_string()
481}
482
483/// The full outbound treatment for a block of model prose: strip structural
484/// noise, then clip to a budget.
485pub fn tighten(text: &str, max: usize, style: &Style) -> String {
486    if !style.terse {
487        return text.trim().to_string();
488    }
489    clip(&strip_empty_sections(text), max)
490}
491
492/// A filed issue's body.
493///
494/// An issue is a work item. Somebody picks it up cold, possibly months later,
495/// with none of the context the pull request thread had, so the rules that keep
496/// a comment short are the wrong rules here. Two things follow.
497///
498/// A fenced code block is never truncated and never counts against the budget.
499/// A snippet cut in half is worse than useless: it is broken markdown and a
500/// misleading fragment of code. Steps to reproduce, a stack trace, the offending
501/// function: those are the reason the issue is worth filing at all.
502///
503/// And when prose does have to be dropped, whole blocks go from the end rather
504/// than a sentence being cut mid-word. What survives is complete.
505pub fn issue_body(text: &str, style: &Style) -> String {
506    if !style.terse {
507        return text.trim().to_string();
508    }
509    let cleaned = strip_empty_sections(text);
510    let blocks = split_blocks(&cleaned);
511
512    // A runaway model pasting an entire file is still worth stopping, so code
513    // is exempt from the prose budget but not from a far looser ceiling.
514    let ceiling = style.max_issue_body_chars.saturating_mul(4);
515
516    let mut kept: Vec<String> = Vec::new();
517    let mut prose = 0usize;
518    let mut total = 0usize;
519    for block in &blocks {
520        let len = block.text.chars().count();
521        if !block.code && prose + len > style.max_issue_body_chars && !kept.is_empty() {
522            break;
523        }
524        if total + len > ceiling {
525            // Past the point GitHub itself would take. Shortening a snippet at
526            // a line boundary with the fence closed still leaves something
527            // usable; dropping it leaves nothing.
528            if block.code {
529                if let Some(short) = shorten_code(&block.text, ceiling.saturating_sub(total)) {
530                    kept.push(short);
531                }
532            }
533            break;
534        }
535        if !block.code {
536            prose += len;
537        }
538        total += len;
539        kept.push(block.text.clone());
540    }
541
542    kept.join("\n\n").trim().to_string()
543}
544
545/// Keep as many whole lines of a fenced block as fit, and close the fence.
546///
547/// Only ever reached by a snippet large enough that GitHub would refuse the
548/// comment outright. Cutting on a line boundary keeps the code readable and
549/// keeps the markdown valid, and the note says plainly that there was more.
550fn shorten_code(block: &str, room: usize) -> Option<String> {
551    const NOTE: &str = "(snippet shortened)";
552    if room < 80 {
553        return None;
554    }
555    let mut lines = block.lines();
556    let opener = lines.next()?.to_string();
557    let mut out = vec![opener];
558    let mut used = out[0].chars().count() + NOTE.len() + 8;
559
560    for line in lines {
561        if line.trim_start().starts_with("```") {
562            break;
563        }
564        let len = line.chars().count() + 1;
565        if used + len > room {
566            break;
567        }
568        used += len;
569        out.push(line.to_string());
570    }
571    out.push("```".to_string());
572    out.push(String::new());
573    out.push(NOTE.to_string());
574    Some(out.join("\n"))
575}
576
577struct Block {
578    text: String,
579    code: bool,
580}
581
582/// Split into paragraphs, keeping every fenced code block whole however many
583/// blank lines it contains.
584fn split_blocks(text: &str) -> Vec<Block> {
585    let mut blocks = Vec::new();
586    let mut current: Vec<&str> = Vec::new();
587    let mut in_fence = false;
588    let mut fence_block = false;
589
590    let flush = |lines: &mut Vec<&str>, code: bool, out: &mut Vec<Block>| {
591        let joined = lines.join("\n");
592        if !joined.trim().is_empty() {
593            out.push(Block {
594                text: joined.trim_end().to_string(),
595                code,
596            });
597        }
598        lines.clear();
599    };
600
601    for line in text.lines() {
602        let fence = line.trim_start().starts_with("```");
603        if fence {
604            if in_fence {
605                current.push(line);
606                in_fence = false;
607                flush(&mut current, true, &mut blocks);
608                fence_block = false;
609                continue;
610            }
611            // A fence starts here, so whatever came before is its own block.
612            flush(&mut current, false, &mut blocks);
613            in_fence = true;
614            fence_block = true;
615            current.push(line);
616            continue;
617        }
618        if in_fence {
619            current.push(line);
620            continue;
621        }
622        if line.trim().is_empty() {
623            flush(&mut current, false, &mut blocks);
624        } else {
625            current.push(line);
626        }
627    }
628    // An unterminated fence is still kept whole rather than split.
629    flush(&mut current, fence_block, &mut blocks);
630    blocks
631}
632
633/// A finding title, issue title, or PR title: always one line, always short.
634pub fn title(text: &str, style: &Style) -> String {
635    let flat = one_line(text);
636    if style.terse {
637        clip_bare(&flat, style.max_title_chars)
638    } else {
639        flat
640    }
641}
642
643/// Capitalise the first letter, so a model's fragment reads as a sentence when
644/// spar sets it after one of its own.
645pub fn sentence(text: &str, style: &Style) -> String {
646    let one = summary(text, style);
647    let mut chars = one.chars();
648    match chars.next() {
649        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
650        None => one,
651    }
652}
653
654/// A one-sentence verdict or disposition reason.
655pub fn summary(text: &str, style: &Style) -> String {
656    let flat = one_line(text);
657    if style.terse {
658        clip(&flat, style.max_summary_chars)
659    } else {
660        flat
661    }
662}
663
664/// A finding's explanation, as it appears in the PR thread. Kept on one line so
665/// a bullet stays a bullet.
666pub fn detail(text: &str, style: &Style) -> String {
667    let flat = one_line(text);
668    if style.terse {
669        clip(&flat, style.max_detail_chars)
670    } else {
671        flat
672    }
673}
674
675/// An issue or PR body. Multi-line is fine here; bloat is not.
676pub fn body(text: &str, style: &Style) -> String {
677    tighten(text, style.max_body_chars, style)
678}
679
680#[cfg(test)]
681mod tests {
682    use super::*;
683
684    fn s() -> Style {
685        Style::default()
686    }
687
688    // -- style gate ------------------------------------------------------
689
690    #[test]
691    fn em_dash_removed() {
692        let out = scrub(
693            "Fix the parser, it was broken \u{2014} badly \u{2014} on empty input.",
694            &s(),
695        );
696        assert!(!out.contains('\u{2014}'));
697        assert!(violations(&out, &s()).is_empty());
698    }
699
700    #[test]
701    fn en_dash_removed() {
702        assert!(!scrub("range 1 \u{2013} 5", &s()).contains('\u{2013}'));
703    }
704
705    #[test]
706    fn horizontal_bar_removed() {
707        assert!(violations(&scrub("a \u{2015} b", &s()), &s()).is_empty());
708    }
709
710    /// Every form that used to pass, one assertion each.
711    ///
712    /// The old patterns caught `co-authored-by:` only when a product name
713    /// followed it directly, and `generated with` only at the start of a line.
714    /// Each of these reached GitHub with the gate seeing nothing, because the
715    /// gate reused the scrub's own patterns.
716    #[test]
717    fn the_forms_that_used_to_pass_are_caught_now() {
718        for text in [
719            "Fix bug. \u{1F916} Generated with [Claude Code](https://claude.com)",
720            "Co-authored-by: Cursor Agent <cursoragent@cursor.com>",
721            "co-authored-by: GitHub Copilot",
722            "Signed-off-by: Claude <noreply@anthropic.com>",
723            "Made with Claude",
724            "Generated by Anthropic's Claude",
725            "Assisted-by: gpt-5",
726        ] {
727            assert!(
728                !violations(text, &s()).is_empty(),
729                "the gate did not see it: {text}"
730            );
731            let out = scrub(text, &s());
732            assert!(
733                violations(&out, &s()).is_empty(),
734                "the scrub could not clean it: {text} -> {out}"
735            );
736        }
737    }
738
739    /// A trailer about a person is an ordinary trailer.
740    #[test]
741    fn a_human_trailer_is_left_exactly_where_it_is() {
742        for text in [
743            "Co-authored-by: Alex Rivera <alex@example.com>",
744            "Signed-off-by: Corey Phillips <corey@example.com>",
745            "Reviewed-by: the release team",
746        ] {
747            assert_eq!(text, scrub(text, &s()), "a person's trailer was rewritten");
748            assert!(violations(text, &s()).is_empty(), "{text}");
749        }
750    }
751
752    /// A dash inside code is evidence, not prose.
753    ///
754    /// An issue body whose reproduction quotes a string containing an em dash
755    /// was rewritten, which removes the very thing the finding is about, and
756    /// changes code somebody is being asked to run.
757    #[test]
758    fn code_keeps_its_dashes_and_its_words() {
759        let text = "The label is wrong:\n\n```rust\nlet s = \"Save \u{2014} Draft\";\n```\n\nFix it \u{2014} soon.";
760        let out = scrub(text, &s());
761        assert!(
762            out.contains("\"Save \u{2014} Draft\""),
763            "the evidence was rewritten:\n{out}"
764        );
765        assert!(
766            out.contains("Fix it, soon."),
767            "prose was not scrubbed:\n{out}"
768        );
769        assert!(violations(&out, &s()).is_empty(), "{out}");
770
771        let inline = scrub("The literal `a \u{2014} b` is the bug \u{2014} here.", &s());
772        assert!(inline.contains("`a \u{2014} b`"), "{inline}");
773        assert!(inline.contains("bug, here."), "{inline}");
774    }
775
776    /// "AI-generated" is a thing people write about spam, not a signature.
777    #[test]
778    fn ordinary_prose_about_ai_survives() {
779        for text in [
780            "Reject AI-generated spam in the comment filter",
781            "The AI generated output is cached",
782        ] {
783            assert_eq!(text, scrub(text, &s()), "prose was edited");
784            assert!(violations(text, &s()).is_empty(), "{text}");
785        }
786    }
787
788    /// The two dashes that were outside the class.
789    #[test]
790    fn the_nonbreaking_hyphen_and_the_two_em_dash_are_dashes() {
791        for dash in ['\u{2011}', '\u{2E3A}', '\u{2E3B}'] {
792            let text = format!("a {dash} b");
793            assert!(
794                !violations(&text, &s()).is_empty(),
795                "{dash:?} passed the gate"
796            );
797            assert!(
798                !scrub(&text, &s()).contains(dash),
799                "{dash:?} survived the scrub"
800            );
801        }
802    }
803
804    /// What the scrub cannot clean has to be the hard error the README
805    /// promises, rather than a leak.
806    #[test]
807    fn a_vendor_address_the_scrub_leaves_is_a_hard_error() {
808        let text = "See the discussion with noreply@anthropic.com about it";
809        assert!(!violations(text, &s()).is_empty(), "{text}");
810    }
811
812    #[test]
813    fn coauthor_trailer_stripped() {
814        let out = scrub(
815            "Add retry logic\n\nCo-Authored-By: Claude Opus 5 <noreply@anthropic.com>\n",
816            &s(),
817        );
818        assert!(!out.contains("Co-Authored-By"));
819        assert!(out.contains("Add retry logic"));
820    }
821
822    #[test]
823    fn generated_with_footer_stripped() {
824        let out = scrub(
825            "Fix bug\n\n\u{1F916} Generated with [Claude Code](https://claude.com)\n",
826            &s(),
827        );
828        assert!(violations(&out, &s()).is_empty(), "{out}");
829        assert!(out.contains("Fix bug"));
830    }
831
832    #[test]
833    fn inline_attribution_stripped() {
834        let out = scrub("This patch was written by Claude to fix the leak.", &s());
835        assert!(violations(&out, &s()).is_empty(), "{out}");
836    }
837
838    #[test]
839    fn scrub_is_idempotent() {
840        let once = scrub("A \u{2014} B\n\nCo-Authored-By: Codex <x@y.z>", &s());
841        assert_eq!(once, scrub(&once, &s()));
842    }
843
844    #[test]
845    fn violations_detected_before_scrub() {
846        assert!(!violations("a \u{2014} b", &s()).is_empty());
847        assert!(!violations("Co-Authored-By: Claude <a@b.c>", &s()).is_empty());
848    }
849
850    #[test]
851    fn legitimate_prose_survives() {
852        let out = scrub("Refactor the AI-facing endpoint handler for clarity.", &s());
853        assert!(out.contains("endpoint handler"), "{out}");
854    }
855
856    #[test]
857    fn disabled_rules_are_respected() {
858        let off = Style {
859            ban_em_dash: false,
860            ban_ai_attribution: false,
861            ..s()
862        };
863        let text = "a \u{2014} b\nCo-Authored-By: Claude <x@y.z>";
864        assert!(scrub(text, &off).contains('\u{2014}'));
865        assert!(violations(text, &off).is_empty());
866    }
867
868    #[test]
869    fn dash_at_end_of_line_does_not_swallow_the_paragraph_break() {
870        let out = scrub("first line \u{2014}\n\nsecond paragraph", &s());
871        assert!(out.contains("\n\n"), "{out:?}");
872    }
873
874    #[test]
875    fn empty_input_is_empty_output() {
876        assert_eq!("", scrub("", &s()));
877    }
878
879    // -- concision gate --------------------------------------------------
880
881    #[test]
882    fn one_line_flattens() {
883        assert_eq!("a b c", one_line("  a\n\n b\t c  "));
884    }
885
886    #[test]
887    fn clip_leaves_short_text_alone() {
888        assert_eq!("short", clip("short", 40));
889    }
890
891    #[test]
892    fn clip_prefers_a_sentence_boundary() {
893        let text = "The loop never terminates. It also leaks a file descriptor on every pass.";
894        assert_eq!("The loop never terminates.", clip(text, 40));
895    }
896
897    #[test]
898    fn clip_falls_back_to_a_word_boundary() {
899        let out = clip("supercalifragilistic wording that runs on and on", 25);
900        assert!(out.ends_with("..."), "{out}");
901        assert!(out.chars().count() <= 25, "{out}");
902        assert!(!out.contains("wording that runs"), "{out}");
903    }
904
905    #[test]
906    /// The budget is a target that rounds up to the end of a sentence, so it
907    /// can be exceeded on purpose. What must hold is that the overshoot is
908    /// bounded: a budget cannot be run away with.
909    fn clip_overshoots_only_within_bounds() {
910        for max in 1..60 {
911            let out = clip("one two three four five six seven eight nine ten.", max);
912            assert!(out.chars().count() <= max * 2 + 3, "max={max} out={out:?}");
913        }
914    }
915
916    #[test]
917    fn clip_handles_multibyte_text() {
918        let out = clip(&"\u{1f600}".repeat(50), 10);
919        assert!(out.chars().count() <= 10, "{out}");
920    }
921
922    /// The sentence-end scan used to look at the last character of the *budget*
923    /// rather than of the *text*, so a period landing exactly on the boundary
924    /// read as the end of a sentence. The result came back with no ellipsis, so
925    /// a truncated file path looked like finished prose.
926    #[test]
927    fn a_period_on_the_budget_boundary_is_not_a_sentence_end() {
928        assert_ne!(
929            "Version 1.",
930            clip("Version 1.4 of the parser mishandles input", 10)
931        );
932        assert_ne!(
933            "Panic in src/style.",
934            clip("Panic in src/style.rs when the budget lands mid word", 19)
935        );
936    }
937
938    #[test]
939    fn an_unmarked_clip_really_did_end_a_sentence() {
940        // The only way to come back without an ellipsis is to stop where the
941        // author stopped.
942        for max in 4..80 {
943            let text = "First sentence here. Second one follows it. Third trails off";
944            let out = clip(text, max);
945            if out.len() < text.len() && !out.ends_with("...") {
946                assert!(
947                    out.ends_with('.') || out.ends_with('!') || out.ends_with('?'),
948                    "max={max} out={out:?}"
949                );
950                let next = text[out.len()..].chars().next();
951                assert!(
952                    next.is_none_or(|c| c.is_whitespace()),
953                    "max={max} cut mid-token before {next:?}: {out:?}"
954                );
955            }
956        }
957    }
958
959    #[test]
960    fn clip_ignores_a_decimal_point_as_a_sentence_end() {
961        let text = "Version 1.4 of the parser mishandles empty input badly and loops.";
962        assert_ne!("Version 1.", clip(text, 30));
963    }
964
965    #[test]
966    fn empty_sections_are_dropped() {
967        let out = strip_empty_sections("## Context\n\n## Proposal\n\nDo the thing.\n");
968        assert!(!out.contains("Context"), "{out}");
969        assert!(out.contains("Do the thing."), "{out}");
970    }
971
972    #[test]
973    fn a_lone_label_heading_is_dropped() {
974        assert_eq!(
975            "The retry never fires.",
976            strip_empty_sections("## Summary\n\nThe retry never fires.")
977        );
978    }
979
980    #[test]
981    fn real_headings_survive_when_there_are_several() {
982        let text = "## Summary\n\nA thing.\n\n## Repro\n\nRun it.";
983        let out = strip_empty_sections(text);
984        assert!(
985            out.contains("## Summary") && out.contains("## Repro"),
986            "{out}"
987        );
988    }
989
990    #[test]
991    fn terse_off_leaves_length_alone() {
992        let loose = Style {
993            terse: false,
994            ..s()
995        };
996        let long = "word ".repeat(400);
997        assert_eq!(long.trim(), detail(&long, &loose));
998    }
999
1000    #[test]
1001    fn detail_is_capped_and_single_line() {
1002        let out = detail(
1003            &format!("first line\nsecond line\n{}", "filler ".repeat(200)),
1004            &s(),
1005        );
1006        assert!(!out.contains('\n'));
1007        assert!(out.chars().count() <= s().max_detail_chars);
1008    }
1009
1010    #[test]
1011    fn title_is_capped_and_single_line() {
1012        let out = title(
1013            "a very\nlong\ttitle that keeps going ".repeat(20).as_str(),
1014            &s(),
1015        );
1016        assert!(!out.contains('\n'));
1017        assert!(out.chars().count() <= s().max_title_chars);
1018    }
1019
1020    #[test]
1021    fn body_keeps_structure_but_bounds_length() {
1022        let text = format!(
1023            "## Summary\n\nreal content here.\n\n{}",
1024            "more prose. ".repeat(300)
1025        );
1026        let out = body(&text, &s());
1027        assert!(
1028            out.chars().count() <= s().max_body_chars,
1029            "{}",
1030            out.chars().count()
1031        );
1032        assert!(out.contains("real content here"), "{out}");
1033    }
1034}
1035
1036#[cfg(test)]
1037mod sentence_tests {
1038    use super::*;
1039
1040    #[test]
1041    fn a_fragment_reads_as_a_sentence() {
1042        assert_eq!(
1043            "The caller already validates it.",
1044            sentence("the caller already validates it.", &Style::default())
1045        );
1046    }
1047
1048    #[test]
1049    fn an_already_capitalised_one_is_untouched() {
1050        assert_eq!(
1051            "Already fine.",
1052            sentence("Already fine.", &Style::default())
1053        );
1054    }
1055
1056    #[test]
1057    fn empty_stays_empty_rather_than_panicking() {
1058        assert_eq!("", sentence("   ", &Style::default()));
1059    }
1060
1061    #[test]
1062    fn a_multibyte_first_character_does_not_panic() {
1063        assert_eq!("Ärger", sentence("ärger", &Style::default()));
1064    }
1065}
1066
1067#[cfg(test)]
1068mod issue_body_tests {
1069    use super::*;
1070
1071    fn s() -> Style {
1072        Style::default()
1073    }
1074
1075    fn fences(text: &str) -> usize {
1076        text.lines()
1077            .filter(|l| l.trim_start().starts_with("```"))
1078            .count()
1079    }
1080
1081    /// The whole point. A snippet cut in half is broken markdown and a
1082    /// misleading fragment of the code somebody is being asked to fix.
1083    #[test]
1084    fn a_code_block_is_never_truncated() {
1085        let code = (0..400)
1086            .map(|n| format!("    line_{n}();"))
1087            .collect::<Vec<_>>()
1088            .join("\n");
1089        let text = format!("It spins forever.\n\n```rust\n{code}\n```\n\nThat is the loop.");
1090        let out = issue_body(&text, &s());
1091
1092        assert!(
1093            out.contains("line_0();") && out.contains("line_399();"),
1094            "the block was cut"
1095        );
1096        assert_eq!(0, fences(&out) % 2, "left an unclosed fence:\n{out}");
1097    }
1098
1099    /// Why there are two functions rather than one budget. The comment path
1100    /// cuts wherever the character count runs out, which on a snippet means an
1101    /// unclosed fence and a misleading half of the code.
1102    #[test]
1103    /// However long the snippet, an issue keeps the fence closed. A comment
1104    /// budget is character counted and knows nothing about fences, which is why
1105    /// issues do not go through it.
1106    fn an_issue_keeps_the_fence_closed_however_long_the_snippet() {
1107        for lines in [50, 400, 4000] {
1108            let code = (0..lines)
1109                .map(|n| format!("    line_{n}();"))
1110                .collect::<Vec<_>>()
1111                .join("\n");
1112            let text = format!("It spins forever.\n\n```rust\n{code}\n```");
1113            let out = issue_body(&text, &s());
1114            assert_eq!(0, fences(&out) % 2, "unclosed fence at {lines} lines");
1115            assert!(
1116                out.contains("line_0();"),
1117                "the snippet went entirely at {lines} lines"
1118            );
1119            // Either the whole snippet survives, or it was shortened on a line
1120            // boundary and says so. Never silently half a snippet.
1121            let whole = out.contains(&format!("line_{}();", lines - 1));
1122            assert!(
1123                whole || out.contains("snippet shortened"),
1124                "cut at {lines} lines without saying so"
1125            );
1126        }
1127    }
1128
1129    /// Code is exempt from the budget, so a long snippet cannot squeeze out the
1130    /// prose that explains it.
1131    #[test]
1132    fn a_long_snippet_does_not_evict_the_explanation() {
1133        let code = "x();\n".repeat(1500);
1134        let text = format!(
1135            "Reproduction:\n\n1. Call connect twice.\n2. Watch the retry count.\n\n```\n{code}```\n\nSuggested fix: bound the loop."
1136        );
1137        let out = issue_body(&text, &s());
1138        assert!(out.contains("Call connect twice"), "{out}");
1139        assert!(out.contains("Suggested fix"), "the tail survived");
1140    }
1141
1142    #[test]
1143    fn steps_to_reproduce_survive_intact() {
1144        let text = "The retry never fires.\n\n1. Start the daemon.\n2. Kill the peer.\n3. Observe connectedToElectrum stays true.\n\nsrc/electrum/index.ts:289 is where the guard is.";
1145        assert_eq!(text, issue_body(text, &s()));
1146    }
1147
1148    #[test]
1149    fn a_body_within_budget_is_untouched() {
1150        let text = "One paragraph.\n\nAnd another.";
1151        assert_eq!(text, issue_body(text, &s()));
1152    }
1153
1154    /// When prose does have to go, whole blocks go from the end. Nothing is cut
1155    /// mid-sentence and nothing gains an ellipsis.
1156    #[test]
1157    fn overlong_prose_drops_whole_blocks_from_the_end() {
1158        let para = |n: usize| format!("Paragraph {n}. {}", "filler words here. ".repeat(30));
1159        let text = (0..20).map(para).collect::<Vec<_>>().join("\n\n");
1160        let out = issue_body(&text, &s());
1161
1162        assert!(out.starts_with("Paragraph 0."), "{out}");
1163        assert!(!out.contains("..."), "no mid-sentence cut: {out}");
1164        assert!(
1165            out.trim_end().ends_with('.'),
1166            "ends on a complete block: {out}"
1167        );
1168        assert!(
1169            out.chars().count() <= s().max_issue_body_chars + 400,
1170            "{}",
1171            out.chars().count()
1172        );
1173    }
1174
1175    /// An issue gets far more room than a pull request comment, because it is
1176    /// read cold by somebody with none of the context.
1177    #[test]
1178    fn an_issue_gets_much_more_room_than_a_comment() {
1179        let text = "word ".repeat(4000);
1180        assert!(
1181            issue_body(&text, &s()).len() > body(&text, &s()).len() * 2,
1182            "issue {} vs comment {}",
1183            issue_body(&text, &s()).len(),
1184            body(&text, &s()).len()
1185        );
1186    }
1187
1188    #[test]
1189    fn a_single_block_over_budget_is_kept_rather_than_mangled() {
1190        let text = format!("```\n{}\n```", "y();\n".repeat(3000));
1191        let out = issue_body(&text, &s());
1192        assert!(!out.is_empty());
1193        assert_eq!(0, fences(&out) % 2, "{}", &out[..80.min(out.len())]);
1194    }
1195
1196    #[test]
1197    fn an_unterminated_fence_is_still_kept_whole() {
1198        let text = "Here is the code:\n\n```rust\nfn broken() {\n    loop {}";
1199        let out = issue_body(text, &s());
1200        assert!(out.contains("fn broken()"), "{out}");
1201    }
1202
1203    #[test]
1204    fn terse_off_leaves_an_issue_body_completely_alone() {
1205        let loose = Style {
1206            terse: false,
1207            ..s()
1208        };
1209        let text = "a".repeat(50_000);
1210        assert_eq!(text, issue_body(&text, &loose));
1211    }
1212
1213    #[test]
1214    fn issue_body_is_idempotent() {
1215        let text = format!(
1216            "Explanation.\n\n```\n{}\n```\n\n{}",
1217            "z();\n".repeat(50),
1218            "more prose. ".repeat(600)
1219        );
1220        let once = issue_body(&text, &s());
1221        assert_eq!(once, issue_body(&once, &s()));
1222    }
1223}
1224
1225#[cfg(test)]
1226mod sentence_completion_tests {
1227    use super::*;
1228
1229    fn s() -> Style {
1230        Style::default()
1231    }
1232
1233    /// The real close comment from beignet#493, which stopped mid-clause and
1234    /// told the reader nothing.
1235    const REAL: &str = "Disconnect() deliberately drops the instance's restore debt and stops \
1236        its poll, so re-arming _restoreOwed there would leave a field nothing consumes, and \
1237        surviving instances already reconnect and restore every remaining hash on their own.";
1238
1239    #[test]
1240    fn the_real_comment_now_finishes_its_sentence() {
1241        let out = summary(REAL, &s());
1242        assert!(!out.ends_with("..."), "{out}");
1243        assert!(out.ends_with('.'), "{out}");
1244        assert!(out.contains("on their own"), "the thought completes: {out}");
1245    }
1246
1247    /// The point of the overshoot: a budget is a target, not a guillotine.
1248    #[test]
1249    fn a_sentence_running_just_past_the_budget_is_finished_not_cut() {
1250        let text = format!("{} and then it ends here.", "word ".repeat(78));
1251        let out = clip(&text, 400);
1252        assert!(out.ends_with("and then it ends here."), "{out}");
1253        assert!(out.chars().count() > 400, "it overshot on purpose");
1254    }
1255
1256    /// But not forever. Prose with no sentence end in sight still gets cut.
1257    #[test]
1258    fn a_sentence_that_never_ends_is_still_cut() {
1259        let text = "word ".repeat(400);
1260        let out = clip(&text, 200);
1261        assert!(out.ends_with("..."), "{out}");
1262        assert!(out.chars().count() <= 200, "{}", out.chars().count());
1263    }
1264
1265    /// Overshoot is for a sentence straddling the budget, not a licence to
1266    /// ignore it. Where sentences end regularly, it stops within budget.
1267    #[test]
1268    fn it_stops_within_budget_when_a_sentence_ends_there() {
1269        let text = "This sentence is complete. ".repeat(30);
1270        let out = clip(&text, 400);
1271        assert!(out.ends_with("complete."), "{out}");
1272        assert!(out.chars().count() <= 400, "{}", out.chars().count());
1273    }
1274
1275    /// And a small budget cannot be run away with by one long sentence.
1276    #[test]
1277    fn overshoot_never_more_than_doubles_the_budget() {
1278        let text = format!("Short. {}", "word ".repeat(400));
1279        let out = clip(&text, 60);
1280        assert!(out.chars().count() <= 120, "{}", out.chars().count());
1281    }
1282
1283    /// Two issues were filed on a real repository with titles ending in a
1284    /// literal ellipsis. A title is not a place for one.
1285    #[test]
1286    fn a_title_never_wears_an_ellipsis() {
1287        let long = "disconnect() stops electrum for clients.network rather than \
1288                    this.electrumNetwork and records the stop against a key nothing reads back"
1289            .to_string();
1290        let out = title(&long, &s());
1291        assert!(!out.ends_with("..."), "{out}");
1292        assert!(!out.contains("..."), "{out}");
1293    }
1294
1295    /// The two real titles that were truncated were 85 and 89 characters after
1296    /// spar cut them. The raised budget keeps titles of that length whole.
1297    #[test]
1298    fn the_titles_that_were_cut_would_now_survive() {
1299        for real in [
1300            "Public subscribeToHeader/subscribeToAddresses re-register an instance disconnect() \
1301             deliberately dropped",
1302            "disconnect() stops electrum for clients.network, not this.electrumNetwork, and \
1303             records the stop against the wrong key",
1304        ] {
1305            let out = title(real, &s());
1306            assert_eq!(one_line(real), out, "still truncated: {out}");
1307        }
1308    }
1309
1310    #[test]
1311    fn the_budgets_leave_room_to_finish_a_thought() {
1312        let d = s();
1313        assert!(d.max_summary_chars >= 400, "a one line reason needs room");
1314        assert!(d.max_detail_chars >= 500);
1315        assert!(d.max_title_chars >= 140);
1316    }
1317
1318    #[test]
1319    fn a_short_text_is_still_left_completely_alone() {
1320        assert_eq!("Already short.", clip("Already short.", 400));
1321        assert_eq!("Already short.", clip_bare("Already short.", 400));
1322    }
1323}