@@ -949,27 +949,14 @@ impl Analyzer {
949949 weak_points : & mut Vec < WeakPoint > ,
950950 file_path : & str ,
951951 ) -> Result < ( ) > {
952- // Detect if this is a test file - Expanded patterns
953- let is_test_file = file_path. contains ( "_tests." )
954- || file_path. contains ( "/tests/" )
955- || file_path. ends_with ( "_test.rs" )
956- || file_path. starts_with ( "tests/" )
957- || file_path. contains ( "/test_" )
958- || file_path. contains ( "_spec." ) // RSpec, Jest patterns
959- || file_path. contains ( "_bench." ) // Benchmark files
960- || file_path. contains ( "/benches/" ) // Rust bench directory
961- || file_path. ends_with ( "_bench.rs" )
962- || file_path. contains ( "__tests__/" ) // JavaScript
963- || file_path. contains ( "/__tests__/" )
964- || file_path. ends_with ( ".test." ) // Generic test files
965- || file_path. ends_with ( ".spec." )
966- || file_path. contains ( "_integration." ) // Integration tests
967- || file_path. contains ( "_e2e." ) // End-to-end tests
968- || file_path. contains ( "_unit." ) // Unit tests
969- || file_path. contains ( "/examples/" ) // Example code (often test-like)
970- || file_path. contains ( "/samples/" ) // Sample code
971- || file_path. contains ( "_mock." ) // Mock files
972- || file_path. contains ( "_stub." ) ; // Stub files
952+ // Use the canonical cross-language classifier rather than a second,
953+ // subtly different Rust-only heuristic. Build scripts are also
954+ // excluded from the panic surface: a build-script panic is a build
955+ // hard-stop, not a production runtime panic in the crate being
956+ // scanned.
957+ let is_test_file = crate :: test_context:: is_test_path ( file_path) ;
958+ let is_build_script = file_path == "build.rs" || file_path. ends_with ( "/build.rs" ) ;
959+ let panic_surface_excluded = is_test_file || is_build_script;
973960
974961 // Strip string literal contents AND comments before counting so that
975962 // detection-tool source files (which embed patterns as string literals)
@@ -997,7 +984,9 @@ impl Analyzer {
997984 // crypto counts too (a `#[test] fn exercises_unsafe_wrapper()`
998985 // inside a production file would otherwise count toward that
999986 // file's unsafe-block total).
1000- let code_only = Self :: strip_cfg_test_modules_rs ( & without_comments) ;
987+ let code_only = Self :: strip_should_panic_items_rs (
988+ & Self :: strip_cfg_test_modules_rs ( & without_comments) ,
989+ ) ;
1001990
1002991 stats. unsafe_blocks += code_only. matches ( "unsafe {" ) . count ( ) ;
1003992 stats. unsafe_blocks += code_only. matches ( "unsafe fn" ) . count ( ) ;
@@ -1016,24 +1005,12 @@ impl Analyzer {
10161005 let unwrap_calls = raw_unwrap + code_only. matches ( ".expect(" ) . count ( ) ;
10171006 let safe_unwrap_calls = safe_unwrap;
10181007
1019- // Apply test file suppression. In test files, normal
1020- // assert-macro use pushes panic/unwrap counts high with no
1021- // production-code signal, so we suppress the counts unless they
1022- // exceed a "clearly excessive" threshold — only the delta above
1023- // the threshold is attributed to the file's statistics.
1024- //
1025- // Panic and unwrap thresholds are evaluated independently: a
1026- // test file with 30 panics + 5 unwraps should report 10 panics
1027- // (30 − 20) and 0 unwraps, not suppress everything because the
1028- // unwrap count is normal. The previous version declared both
1029- // thresholds but only compared panics, silently dropping any
1030- // excessive unwrap signal.
1031- let ( effective_panic_sites, effective_unwrap_calls) = if is_test_file {
1032- let panic_threshold = 20 ;
1033- let unwrap_threshold = 10 ;
1034- let eff_panics = panic_sites. saturating_sub ( panic_threshold) ;
1035- let eff_unwraps = unwrap_calls. saturating_sub ( unwrap_threshold) ;
1036- ( eff_panics, eff_unwraps)
1008+ // Test, benchmark, #[should_panic], and build-script code is not part
1009+ // of the production panic surface. Suppress it completely; a
1010+ // threshold-based exception makes a sufficiently large test suite
1011+ // look like production code and defeats the precision contract.
1012+ let ( effective_panic_sites, effective_unwrap_calls) = if panic_surface_excluded {
1013+ ( 0 , 0 )
10371014 } else {
10381015 ( panic_sites, unwrap_calls) // Production code: count all
10391016 } ;
@@ -1138,16 +1115,16 @@ impl Analyzer {
11381115 } ) ;
11391116 }
11401117
1141- if stats . unwrap_calls > 5 {
1118+ if effective_panic_sites > 0 || effective_unwrap_calls > 0 {
11421119 weak_points. push ( WeakPoint {
11431120 file : None ,
11441121 line : None ,
11451122 category : WeakPointCategory :: PanicPath ,
11461123 location : Some ( file_path. to_string ( ) ) ,
11471124 severity : Severity :: Medium ,
11481125 description : format ! (
1149- "{} unwrap/expect calls in {}" ,
1150- stats . unwrap_calls , file_path
1126+ "{} panic sites and {} unwrap/expect calls in {}" ,
1127+ effective_panic_sites , effective_unwrap_calls , file_path
11511128 ) ,
11521129 recommended_attack : vec ! [ AttackAxis :: Memory , AttackAxis :: Disk ] ,
11531130 suppressed : false ,
@@ -1543,14 +1520,15 @@ impl Analyzer {
15431520 out
15441521 }
15451522
1546- /// Strip the bodies of `#[cfg(test)] mod <name> { … }` blocks from
1547- /// `content`, leaving the attribute, `mod` keyword, name, and
1548- /// enclosing braces in place but replacing everything between the
1523+ /// Strip the bodies of `#[cfg(test)]` modules and functions from
1524+ /// `content`, leaving the attribute, item signature, and enclosing
1525+ /// braces in place but replacing everything between the
15491526 /// braces with whitespace. Newlines in the body are preserved so
15501527 /// line numbers downstream stay stable.
15511528 ///
15521529 /// This is the Rust analogue of Zig's `count_unsafe_in_test_blocks`
1553- /// and is used to treat an inline `#[cfg(test)] mod tests { … }`
1530+ /// and is used to treat an inline `#[cfg(test)] mod tests { … }` or
1531+ /// `#[cfg(test)] fn test() { … }`
15541532 /// inside a production file as test context for substring-based
15551533 /// dangerous-pattern checks — the same way a whole file under
15561534 /// `/tests/` is already treated as test context by `is_test_file`.
@@ -1628,26 +1606,42 @@ impl Analyzer {
16281606 }
16291607 }
16301608
1631- if k + 4 > n || & bytes[ k..k + 4 ] != b"mod " {
1609+ let is_mod = k + 4 <= n && & bytes[ k..k + 4 ] == b"mod " ;
1610+ let is_fn = ( k + 3 <= n && & bytes[ k..k + 3 ] == b"fn " )
1611+ || ( k + 8 <= n && & bytes[ k..k + 8 ] == b"async fn" ) ;
1612+ if !is_mod && !is_fn {
16321613 out. extend_from_slice ( & bytes[ attr_start..attr_end] ) ;
16331614 i = attr_end;
16341615 continue ;
16351616 }
1636- k += 4 ;
1637- while k < n && ( bytes[ k] as char ) . is_whitespace ( ) {
1638- k += 1 ;
1639- }
1640- while k < n && ( bytes[ k] . is_ascii_alphanumeric ( ) || bytes[ k] == b'_' ) {
1641- k += 1 ;
1642- }
1643- while k < n && ( bytes[ k] as char ) . is_whitespace ( ) {
1644- k += 1 ;
1645- }
1646-
1647- if k >= n || bytes[ k] != b'{' {
1648- out. extend_from_slice ( & bytes[ attr_start..attr_end] ) ;
1649- i = attr_end;
1650- continue ;
1617+ if is_mod {
1618+ k += 4 ;
1619+ while k < n && ( bytes[ k] as char ) . is_whitespace ( ) {
1620+ k += 1 ;
1621+ }
1622+ while k < n && ( bytes[ k] . is_ascii_alphanumeric ( ) || bytes[ k] == b'_' ) {
1623+ k += 1 ;
1624+ }
1625+ while k < n && ( bytes[ k] as char ) . is_whitespace ( ) {
1626+ k += 1 ;
1627+ }
1628+ if k >= n || bytes[ k] != b'{' {
1629+ out. extend_from_slice ( & bytes[ attr_start..attr_end] ) ;
1630+ i = attr_end;
1631+ continue ;
1632+ }
1633+ } else {
1634+ // A function signature has parameters and possibly a return
1635+ // type between its name and body; scan to the first body
1636+ // brace while leaving declarations without a body untouched.
1637+ while k < n && bytes[ k] != b'{' && bytes[ k] != b';' {
1638+ k += 1 ;
1639+ }
1640+ if k >= n || bytes[ k] != b'{' {
1641+ out. extend_from_slice ( & bytes[ attr_start..attr_end] ) ;
1642+ i = attr_end;
1643+ continue ;
1644+ }
16511645 }
16521646
16531647 let body_start = k + 1 ;
@@ -1678,6 +1672,78 @@ impl Analyzer {
16781672 String :: from_utf8_lossy ( & out) . into_owned ( )
16791673 }
16801674
1675+ /// Strip the bodies of `#[should_panic]` items from Rust source while
1676+ /// preserving line numbers. `#[should_panic]` is meaningful on test
1677+ /// functions even when the function is not inside a `#[cfg(test)]` module
1678+ /// or a conventional `tests/` path.
1679+ fn strip_should_panic_items_rs ( content : & str ) -> String {
1680+ let bytes = content. as_bytes ( ) ;
1681+ let n = bytes. len ( ) ;
1682+ let mut out = Vec :: with_capacity ( n) ;
1683+ let mut i = 0 ;
1684+
1685+ while i < n {
1686+ if i + 14 > n || & bytes[ i..i + 14 ] != b"#[should_panic" {
1687+ out. push ( bytes[ i] ) ;
1688+ i += 1 ;
1689+ continue ;
1690+ }
1691+
1692+ let attr_start = i;
1693+ let mut attr_end = i + 14 ;
1694+ while attr_end < n && bytes[ attr_end] != b']' {
1695+ attr_end += 1 ;
1696+ }
1697+ if attr_end >= n {
1698+ out. push ( bytes[ i] ) ;
1699+ i += 1 ;
1700+ continue ;
1701+ }
1702+ attr_end += 1 ;
1703+
1704+ let mut body_start = attr_end;
1705+ while body_start < n && ( bytes[ body_start] as char ) . is_whitespace ( ) {
1706+ body_start += 1 ;
1707+ }
1708+ let mut brace = body_start;
1709+ while brace < n && bytes[ brace] != b'{' && bytes[ brace] != b';' {
1710+ brace += 1 ;
1711+ }
1712+ // A malformed attribute or an item without a body is left
1713+ // untouched so the detector fails open.
1714+ if brace >= n || bytes[ brace] != b'{' {
1715+ out. extend_from_slice ( & bytes[ attr_start..attr_end] ) ;
1716+ i = attr_end;
1717+ continue ;
1718+ }
1719+
1720+ let mut depth = 1i32 ;
1721+ let mut end = brace + 1 ;
1722+ while end < n && depth > 0 {
1723+ match bytes[ end] {
1724+ b'{' => depth += 1 ,
1725+ b'}' => depth -= 1 ,
1726+ _ => { }
1727+ }
1728+ end += 1 ;
1729+ }
1730+ if depth != 0 {
1731+ out. extend_from_slice ( & bytes[ attr_start..attr_end] ) ;
1732+ i = attr_end;
1733+ continue ;
1734+ }
1735+
1736+ out. extend_from_slice ( & bytes[ attr_start..brace + 1 ] ) ;
1737+ for byte in & bytes[ brace + 1 ..end - 1 ] {
1738+ out. push ( if * byte == b'\n' { b'\n' } else { b' ' } ) ;
1739+ }
1740+ out. push ( b'}' ) ;
1741+ i = end;
1742+ }
1743+
1744+ String :: from_utf8_lossy ( & out) . into_owned ( )
1745+ }
1746+
16811747 /// Classify the argument list of `#[cfg(...)]` as selecting for
16821748 /// `test` (true) or not (false). Uses [`strip_not_test_groups`] to
16831749 /// drop `not(test)` groups first, then looks for a bareword `test`.
@@ -6566,26 +6632,18 @@ fn safe_wrapper() {
65666632 }
65676633
65686634 // ---------------------------------------------------------------
6569- // 5. analyze() on a Rust file containing `.unwrap()` — PanicPath
6635+ // 5. analyze() on a Rust file containing one explicit panic — PanicPath
65706636 // ---------------------------------------------------------------
65716637
65726638 #[ test]
6573- fn analyze_rust_detects_panic_path_from_unwrap ( ) {
6639+ fn analyze_rust_detects_panic_path_from_explicit_panic ( ) {
65746640 let tmp = TempDir :: new ( ) . unwrap ( ) ;
6575- let rust_file = tmp. path ( ) . join ( "unwrappy.rs" ) ;
6576- // The analyzer triggers PanicPath when unwrap_calls > 5,
6577- // so we need at least 6 unwrap calls.
6641+ let rust_file = tmp. path ( ) . join ( "panic_control.rs" ) ;
65786642 fs:: write (
65796643 & rust_file,
65806644 r#"
6581- fn lots_of_unwraps() {
6582- let a = Some(1).unwrap();
6583- let b = Some(2).unwrap();
6584- let c = Some(3).unwrap();
6585- let d = Some(4).unwrap();
6586- let e = Some(5).unwrap();
6587- let f = Some(6).unwrap();
6588- let g = Some(7).unwrap();
6645+ fn production_positive_control() {
6646+ panic!("production positive control");
65896647}
65906648"# ,
65916649 )
@@ -6602,7 +6660,7 @@ fn lots_of_unwraps() {
66026660
66036661 assert ! (
66046662 !panic_points. is_empty( ) ,
6605- "Should detect PanicPath weak point when >5 unwrap() calls are present "
6663+ "Should detect a single production panic "
66066664 ) ;
66076665 }
66086666
@@ -6768,21 +6826,19 @@ func main() {
67686826 }
67696827
67706828 // ---------------------------------------------------------------
6771- // 11. Rust file with few unwraps should NOT trigger PanicPath
6829+ // 11. Rust file with one production unwrap should trigger PanicPath
67726830 // ---------------------------------------------------------------
67736831
67746832 #[ test]
6775- fn analyze_rust_few_unwraps_no_panic_path ( ) {
6833+ fn analyze_rust_single_unwrap_triggers_panic_path ( ) {
67766834 let tmp = TempDir :: new ( ) . unwrap ( ) ;
67776835 let rust_file = tmp. path ( ) . join ( "safe.rs" ) ;
6778- // Only 3 unwrap calls — threshold is >5
6836+ // A single production unwrap is sufficient for the recall contract.
67796837 fs:: write (
67806838 & rust_file,
67816839 r#"
6782- fn few_unwraps () {
6840+ fn one_unwrap () {
67836841 let a = Some(1).unwrap();
6784- let b = Some(2).unwrap();
6785- let c = Some(3).unwrap();
67866842}
67876843"# ,
67886844 )
@@ -6797,10 +6853,7 @@ fn few_unwraps() {
67976853 . filter ( |wp| wp. category == WeakPointCategory :: PanicPath )
67986854 . collect ( ) ;
67996855
6800- assert ! (
6801- panic_points. is_empty( ) ,
6802- "Should NOT trigger PanicPath when unwrap count is <= 5"
6803- ) ;
6856+ assert ! ( !panic_points. is_empty( ) , "A production unwrap must be reported" ) ;
68046857 }
68056858
68066859 // ---------------------------------------------------------------
@@ -7519,6 +7572,17 @@ trailing_production_code();
75197572 assert ! ( out. contains( "fn prod" ) ) ;
75207573 }
75217574
7575+ #[ test]
7576+ fn strip_cfg_test_function_and_should_panic_bodies ( ) {
7577+ let src = "#[cfg(test)]\n fn cfg_test() { panic!(\" cfg\" ); }\n #[should_panic]\n fn expected() { panic!(\" expected\" ); }\n fn prod() { panic!(\" prod\" ); }" ;
7578+ let cfg_stripped = Analyzer :: strip_cfg_test_modules_rs ( src) ;
7579+ let out = Analyzer :: strip_should_panic_items_rs ( & cfg_stripped) ;
7580+ assert ! ( !out. contains( "panic!(\" cfg\" )" ) ) ;
7581+ assert ! ( !out. contains( "panic!(\" expected\" )" ) ) ;
7582+ assert ! ( out. contains( "panic!(\" prod\" )" ) ) ;
7583+ assert_eq ! ( out. lines( ) . count( ) , src. lines( ) . count( ) ) ;
7584+ }
7585+
75227586 #[ test]
75237587 fn strip_cfg_test_modules_handles_pub_mod ( ) {
75247588 let src = "\
0 commit comments