diff --git a/backend/src/api/audit/full.rs b/backend/src/api/audit/full.rs index 78373854..9acb4dac 100644 --- a/backend/src/api/audit/full.rs +++ b/backend/src/api/audit/full.rs @@ -1797,7 +1797,7 @@ pub async fn full_audit( crate::db::audit_runs::update_td_counts( &tx, &run_id, counts.critical, counts.high, counts.medium, counts.low, - carried, + resolved, new, carried, )?; } tx.commit()?; diff --git a/backend/src/api/audit/validation.rs b/backend/src/api/audit/validation.rs index f6556f20..316100be 100644 --- a/backend/src/api/audit/validation.rs +++ b/backend/src/api/audit/validation.rs @@ -306,13 +306,25 @@ pub fn check_detector_disposition( /// Liquid / Mermaid syntax that uses `{{` for its own purposes (e.g. /// `{{ asset('foo') }}` in a Twig snippet inside coding-rules.md, or /// `{{ DECISION_1 }}` is matched, but `{{ asset(...) }}` is not). +/// Fenced blocks and inline code spans are invisible to the counter — +/// documenting a placeholder is not the same as leaving one unfilled +/// (benchmark run 1 false positive: the TD index mentioning +/// `{{DO_NOT_1}}` in inline code made a 43-TD file read as "still the +/// template"). Reuses the anti-hallu strippers: inline spans pair +/// N-backtick runs (an UNCLOSED run keeps its text — a stray backtick can +/// never hide a real slot, bounded pairing on hostile lines); fences are +/// lexical, and an UNCLOSED fence restores everything it withheld, +/// opening line included. pub(crate) fn count_raw_placeholders(content: &str) -> usize { + let stripped = crate::core::anti_halluc::strip_inline_code( + &crate::core::anti_halluc::strip_fenced_code(content), + ); // Match `{{IDENT}}` and `{{ IDENT }}` where IDENT is // UPPERCASE_SNAKE (with optional digits + _). The trailing // boundary is a literal `}}`, not just `}`, to avoid hits on // Twig double-brace blocks that contain spaces / parens. let mut count = 0usize; - let mut rest = content; + let mut rest = stripped.as_str(); while let Some(start) = rest.find("{{") { let after_open = &rest[start + 2..]; // Find closing `}}` @@ -786,6 +798,59 @@ mod tests { assert_eq!(count_raw_placeholders(body), 2); } + #[test] + fn count_raw_placeholders_ignores_code_spans_and_fences() { + // Benchmark run 1 (2026-07-21) false positive: the TD index + // DOCUMENTED `{{DO_NOT_1}}`/`{{DO_NOT_2}}` in inline code while + // describing a redirector stub — a 43-TD file read as "still the + // template" and failed step 8. Documenting a placeholder in code + // is content, not an unfilled slot. + assert_eq!(count_raw_placeholders("mentions `{{DO_NOT_1}}` in prose"), 0); + assert_eq!( + count_raw_placeholders("it carries `{{DO_NOT_1}}`/`{{DO_NOT_2}}` placeholders"), + 0 + ); + assert_eq!( + count_raw_placeholders("```\n| {{ID}} |\n```\nafter the fence"), + 0 + ); + // CommonMark double-backtick spans strip too (a naive char toggle + // would leave the inner token counted). + assert_eq!(count_raw_placeholders("``{{ID}}``"), 0); + // An UNCLOSED fence (truncated markdown) must never hide a real + // slot sitting after it — fail-closed, like the stray backtick. + assert_eq!( + count_raw_placeholders("```text\nexemple tronqué\n| {{ID}} |"), + 1 + ); + // The OPENING line of an unclosed fence must not swallow a slot + // sitting on it either. + assert_eq!(count_raw_placeholders("```text {{ID}}\ntruncated"), 1); + // A ```suffix line is NOT a valid closer: no closer before EOF ⇒ + // everything restored ⇒ the slot stays visible. + assert_eq!( + count_raw_placeholders("```text\n| {{ID}} |\n```still-code"), + 1 + ); + // A backtick inside the info string is NOT a valid opener — the + // slot after it stays visible. + assert_eq!( + count_raw_placeholders("```lang`oops\n| {{ID}} |\n```"), + 1 + ); + // A REAL unfilled slot outside code still counts — including next + // to documented ones. + assert_eq!(count_raw_placeholders("| {{ID}} |"), 1); + assert_eq!( + count_raw_placeholders("mentions `{{DOC}}` but leaves | {{ID}} | unfilled"), + 1 + ); + // An UNCLOSED backtick run keeps its text: a stray backtick (typo) + // must never hide a real unfilled slot behind it. + assert_eq!(count_raw_placeholders("stray ` then | {{ID}} |"), 1); + assert_eq!(count_raw_placeholders("| {{ID}} | then stray `"), 1); + } + #[test] fn normalized_hash_ignores_cosmetic_touches_only() { // Matrix v2 — a cosmetic touch (CRLF, trailing ws) must not read as diff --git a/backend/src/core/anti_halluc.rs b/backend/src/core/anti_halluc.rs index babb9e83..13d7d829 100644 --- a/backend/src/core/anti_halluc.rs +++ b/backend/src/core/anti_halluc.rs @@ -628,19 +628,73 @@ fn is_heading_or_imperative(s: &str) -> bool { /// Strip fenced ```code blocks``` — agents emit lots of code and linting it /// would be pure noise. Returns the text with fenced regions removed. -fn strip_fenced_code(text: &str) -> String { +/// +/// Fail-closed semantics (benchmark hotfix, 2026-07-21): +/// - a fence OPENS on a run of ≥3 backticks indented ≤3 spaces (info +/// string allowed); anything more indented or shorter is content; +/// - it CLOSES only on a run of the same character, at least as long, +/// indented ≤3 spaces, with a whitespace-only suffix — a ```suffix line +/// is CONTENT, never a closer; +/// - everything withheld (opening line included) is RESTORED verbatim if +/// the document ends before a valid closer: a truncated markdown must +/// never hide real content from a fail-closed consumer (the step-8 +/// placeholder validator feeds on this). +pub(crate) fn strip_fenced_code(text: &str) -> String { + /// `Some((run_len, suffix))` when the line is a fence marker candidate: + /// ≤3 leading spaces then a run of ≥3 backticks. Tabs disqualify. + fn fence_marker(line: &str) -> Option<(usize, &str)> { + let stripped = line.trim_start_matches(' '); + if line.len() - stripped.len() > 3 { + return None; // 4+ spaces = indented code, not a fence + } + let run = stripped.chars().take_while(|&c| c == '`').count(); + if run < 3 { + return None; + } + Some((run, &stripped[run..])) + } + let mut out = String::with_capacity(text.len()); - let mut in_fence = false; + // (opening run length, withheld lines — restored verbatim on EOF) + let mut fence: Option<(usize, String)> = None; for line in text.lines() { - if line.trim_start().starts_with("```") { - in_fence = !in_fence; - continue; - } - if !in_fence { - out.push_str(line); - out.push('\n'); + match fence.as_mut() { + None => { + // A backtick fence's info string may not contain a backtick + // (CommonMark): ```lang`oops is CONTENT, not an opener — a + // false opener must never swallow a real slot. + match fence_marker(line) { + Some((run, info)) if !info.contains('`') => { + let mut buf = String::new(); + buf.push_str(line); + buf.push('\n'); + fence = Some((run, buf)); + } + _ => { + out.push_str(line); + out.push('\n'); + } + } + } + Some((open_run, buf)) => match fence_marker(line) { + // Closer suffix: spaces/tabs ONLY — Unicode whitespace must + // not promote a content line into a closer. + Some((run, suffix)) + if run >= *open_run + && suffix.chars().all(|c| c == ' ' || c == '\t') => + { + fence = None; // valid closer: the withheld block is code — drop it + } + _ => { + buf.push_str(line); + buf.push('\n'); + } + }, } } + if let Some((_, buf)) = fence { + out.push_str(&buf); + } out } @@ -657,7 +711,7 @@ const MAX_BACKTICK_RUNS_PER_LINE: usize = 64; /// kept verbatim — backticks in plain prose never eat the rest of the line. /// Runs are pre-scanned once per line and their count is capped, so a long /// hostile line can't turn the pairing into a CPU hotspot (Copilot review). -fn strip_inline_code(text: &str) -> String { +pub(crate) fn strip_inline_code(text: &str) -> String { let mut out = String::with_capacity(text.len()); for line in text.lines() { // Fast path — the overwhelmingly common case. @@ -1585,6 +1639,64 @@ pub fn finalize_lint_report( mod tests { use super::*; + #[test] + fn strip_fenced_code_drops_closed_blocks_but_restores_unclosed_ones() { + // Closed fence: content is code, dropped. + assert_eq!( + strip_fenced_code("before\n```\ncode\n```\nafter"), + "before\nafter\n" + ); + // UNCLOSED fence (truncated document): the withheld content is + // restored — a fail-closed consumer (step-8 placeholder validator) + // must never lose sight of real content behind a stray ```. + assert_eq!( + strip_fenced_code("```text\ntruncated example\n| {{ID}} |"), + "```text\ntruncated example\n| {{ID}} |\n" + ); + // Two blocks, second unclosed: first dropped, second restored + // (including ITS opening line). + assert_eq!( + strip_fenced_code("```\na\n```\nkeep\n```\nb"), + "keep\n```\nb\n" + ); + // The opening line itself is withheld-then-restored: an info-string + // carrying real content must survive an unclosed fence. + assert_eq!( + strip_fenced_code("```text {{ID}}\ntruncated"), + "```text {{ID}}\ntruncated\n" + ); + // A ```suffix line is CONTENT, not a closer — without a valid + // closer the whole block is restored. + assert_eq!( + strip_fenced_code("```text\n| {{ID}} |\n```still-code"), + "```text\n| {{ID}} |\n```still-code\n" + ); + // A closer run SHORTER than the opening run does not close. + assert_eq!( + strip_fenced_code("````\n| {{ID}} |\n```\ntail"), + "````\n| {{ID}} |\n```\ntail\n" + ); + // 4-space indentation = indented code, not a fence at all. + assert_eq!( + strip_fenced_code(" ```\n| {{ID}} |"), + " ```\n| {{ID}} |\n" + ); + // Trailing whitespace on a closer is fine (CommonMark). + assert_eq!(strip_fenced_code("```\ncode\n``` "), ""); + // A backtick in the info string invalidates the OPENER: the line is + // content and nothing after it is withheld. + assert_eq!( + strip_fenced_code("```lang`oops\n| {{ID}} |\n```"), + "```lang`oops\n| {{ID}} |\n```\n" + ); + // Unicode whitespace in a closer suffix does NOT close (spaces and + // tabs only) — the block stays unclosed and is restored. + assert_eq!( + strip_fenced_code("```\n| {{ID}} |\n```\u{00A0}"), + "```\n| {{ID}} |\n```\u{00A0}\n" + ); + } + // ── Mode ────────────────────────────────────────────────────────── #[test] diff --git a/backend/src/db/audit_runs.rs b/backend/src/db/audit_runs.rs index 33aa34f4..c76cf39f 100644 --- a/backend/src/db/audit_runs.rs +++ b/backend/src/db/audit_runs.rs @@ -328,6 +328,7 @@ pub fn update_last_completed_step(conn: &Connection, id: &str, step: u32) -> Res /// Stamp the TD counters computed at end-of-stream onto a row regardless of /// its terminal status. Interrupted runs used to keep td_* at 0 while 14 TD /// files sat on disk — the history chip strip under-reported real findings. +#[allow(clippy::too_many_arguments)] pub fn update_td_counts( conn: &Connection, id: &str, @@ -335,15 +336,31 @@ pub fn update_td_counts( td_high: u32, td_medium: u32, td_low: u32, + td_resolved_since_last: u32, + td_new_since_last: u32, td_carried_over: u32, ) -> Result<()> { + // resolved/new are persisted too (benchmark run 1, 2026-07-21): the + // Interrupted path computed them but left the row at its 0 defaults, + // and the UI presented "0 new" as a KNOWN value while a genuinely new + // TD existed on disk. conn.execute( "UPDATE audit_runs SET td_critical = ?2, td_high = ?3, td_medium = ?4, td_low = ?5, - td_total = ?6, td_carried_over = ?7 + td_total = ?6, td_resolved_since_last = ?7, + td_new_since_last = ?8, td_carried_over = ?9 WHERE id = ?1", - params![id, td_critical, td_high, td_medium, td_low, - td_critical + td_high + td_medium + td_low, td_carried_over], + params![ + id, + td_critical, + td_high, + td_medium, + td_low, + td_critical + td_high + td_medium + td_low, + td_resolved_since_last, + td_new_since_last, + td_carried_over + ], )?; Ok(()) } @@ -752,17 +769,26 @@ mod tests { #[test] fn update_td_counts_stamps_interrupted_runs() { // The TD files exist on disk even when the run ends Interrupted — - // the history row must not report 0 findings. + // the history row must not report 0 findings. Benchmark run 1 + // (2026-07-21): resolved/new were computed but never persisted on + // this path, so a genuinely new TD read as "0 new" — the exact + // reconciliation partition (13 carried + 1 new, 2 resolved) must + // land in the row. let conn = fresh_conn(); let start = Utc::now(); insert_running(&conn, "r1", "p1", "Full", "ClaudeCode", start).unwrap(); mark_interrupted(&conn, "r1", "warned steps: [1]").unwrap(); - update_td_counts(&conn, "r1", 0, 2, 8, 4, 14).unwrap(); + update_td_counts(&conn, "r1", 0, 2, 8, 4, 2, 1, 13).unwrap(); let row = &list_recent(&conn, "p1", 1).unwrap()[0]; assert_eq!(row.status, "Interrupted"); assert_eq!(row.td_total, 14); assert_eq!(row.td_high, 2); - assert_eq!(row.td_carried_over, 14); + assert_eq!(row.td_resolved_since_last, 2); + assert_eq!( + row.td_new_since_last, 1, + "a new TD on an interrupted run must not read as 0" + ); + assert_eq!(row.td_carried_over, 13); } #[test]