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