From e8cc505a1dceb1ced19b3dcba2fe3726de92fe72 Mon Sep 17 00:00:00 2001 From: Postil Maintainer Date: Fri, 14 Aug 2026 00:42:12 +0000 Subject: [PATCH] fix: preserve operators in review code spans --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/envelope.rs | 104 +++++++++++++++++++++++++++++++++++++++++++- src/forge/github.rs | 9 +++- 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b80808b..960f736 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1104,7 +1104,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "postil-cli" -version = "0.8.15" +version = "0.8.16" dependencies = [ "aho-corasick", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index ceb5e4d..a49ac65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "postil-cli" -version = "0.8.15" +version = "0.8.16" edition = "2024" description = "Postil: a low-noise AI review gate. Silent on clean PRs, hard gate on real risk." license = "Apache-2.0" diff --git a/src/envelope.rs b/src/envelope.rs index 4792473..c65d36b 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -720,7 +720,7 @@ fn normalize_publication_body_line(value: &str, index: usize) -> String { normalized.push_str(&line[..first_content]); normalized.push('\\'); normalized.push_str(&line[first_content..]); - normalized + neutralize_unmatched_backticks(&sanitize_publication_line(&normalized)) } fn sanitize_publication_title(value: &str) -> String { @@ -734,7 +734,8 @@ fn sanitize_publication_title(value: &str) -> String { } }) .collect::(); - let line = sanitize_publication_line(&single_line).replace(['`', '*', '[', ']', '#'], " "); + let line = + sanitize_publication_plain_line(&single_line).replace(['`', '*', '[', ']', '#'], " "); let characters = line.chars().collect::>(); let line = characters .iter() @@ -756,6 +757,16 @@ fn sanitize_publication_title(value: &str) -> String { } fn sanitize_publication_line(value: &str) -> String { + let text = escape_unsafe_unicode(value) + .chars() + .filter(|character| !character.is_control() || *character == '\t') + .collect::() + .replace('@', "@"); + let text = escape_html_outside_inline_code(&text); + neutralize_markdown_images(&text) +} + +fn sanitize_publication_plain_line(value: &str) -> String { let text = escape_unsafe_unicode(value) .chars() .filter(|character| !character.is_control() || *character == '\t') @@ -766,6 +777,64 @@ fn sanitize_publication_line(value: &str) -> String { neutralize_markdown_images(&text) } +fn escape_html_outside_inline_code(value: &str) -> String { + let characters = value.chars().collect::>(); + let mut output = String::with_capacity(value.len()); + let mut cursor = 0; + while cursor < characters.len() { + if characters[cursor] != '`' { + match characters[cursor] { + '<' => output.push_str("<"), + '>' => output.push_str(">"), + character => output.push(character), + } + cursor += 1; + continue; + } + + let preceding_backslashes = characters[..cursor] + .iter() + .rev() + .take_while(|character| **character == '\\') + .count(); + let width = characters[cursor..] + .iter() + .take_while(|character| **character == '`') + .count(); + if preceding_backslashes % 2 == 1 { + output.extend(characters[cursor..cursor + width].iter()); + cursor += width; + continue; + } + + let mut candidate = cursor + width; + let mut closing = None; + while candidate < characters.len() { + if characters[candidate] != '`' { + candidate += 1; + continue; + } + let candidate_width = characters[candidate..] + .iter() + .take_while(|character| **character == '`') + .count(); + if candidate_width == width { + closing = Some(candidate + width); + break; + } + candidate += candidate_width; + } + if let Some(end) = closing { + output.extend(characters[cursor..end].iter()); + cursor = end; + } else { + output.extend(characters[cursor..cursor + width].iter()); + cursor += width; + } + } + output +} + fn escape_unsafe_unicode(value: &str) -> String { let mut output = String::with_capacity(value.len()); for character in value.chars() { @@ -1817,6 +1886,37 @@ mod tests { assert_eq!(validate_finding_publication(&finding), Ok(())); } + #[test] + fn fresh_finding_normalization_preserves_operators_in_inline_code() { + let mut finding = finding(Severity::Warn, 0.9); + finding.body = "The expression `time() - kube_pod_start_time > 60d` measures pod age, while
remains markup.".into(); + + normalize_finding_publication(&mut finding); + + assert!( + finding + .body + .contains("`time() - kube_pod_start_time > 60d`") + ); + assert!(!finding.body.contains("> 60d`")); + assert!(finding.body.contains("<details>")); + assert_eq!(validate_finding_publication(&finding), Ok(())); + } + + #[test] + fn fresh_finding_normalization_is_idempotent_for_fence_shaped_code() { + let mut finding = finding(Severity::Warn, 0.9); + finding.body = "```
```.".into(); + + normalize_finding_publication(&mut finding); + let normalized = finding.body.clone(); + + assert_eq!(normalized, "\\``` <details> ```."); + assert_eq!(validate_finding_publication(&finding), Ok(())); + normalize_finding_publication(&mut finding); + assert_eq!(finding.body, normalized); + } + #[test] fn fresh_finding_normalization_does_not_hide_semantic_contract_failures() { let mut incomplete = finding(Severity::Warn, 0.9); diff --git a/src/forge/github.rs b/src/forge/github.rs index f5184b1..92c880c 100644 --- a/src/forge/github.rs +++ b/src/forge/github.rs @@ -3065,7 +3065,11 @@ mod tests { async fn github_receipt_separates_inline_summary_carried_resolved_and_suppressed() { let server = MockServer::start().await; mount_current_delivery_snapshot(&server).await; - let inline = publication_finding("inline-1", "src/lib.rs", "A concrete issue."); + let inline = publication_finding( + "inline-1", + "src/lib.rs", + "The expression `time() - kube_pod_start_time > 60d` measures pod age.", + ); let synthetic = publication_finding( "summary-1", crate::envelope::PR_DESCRIPTION_PATH, @@ -3164,6 +3168,9 @@ mod tests { .unwrap(); let initial_summary = review["body"].as_str().unwrap(); assert!(!initial_summary.contains("posted inline")); + let inline_body = review["comments"][0]["body"].as_str().unwrap(); + assert!(inline_body.contains("`time() - kube_pod_start_time > 60d`")); + assert!(!inline_body.contains("> 60d`")); let update: serde_json::Value = serde_json::from_slice( &requests .iter()