@@ -160,6 +160,41 @@ fn read_and_validate_file(path: &Path) -> anyhow::Result<(String, ModModule)> {
160160 }
161161}
162162
163+ /// Linter pragma prefixes (lowercase) that, when present on the line directly
164+ /// above a target line, indicate we should NOT insert a new pyrefly suppression
165+ /// line between them — the foreign pragma applies to the *next* line and a
166+ /// pyrefly comment inserted between them would silently disable it. See #3373.
167+ const FOREIGN_LINTER_PRAGMAS : & [ & str ] = & [
168+ "noqa" ,
169+ "nosemgrep" ,
170+ "nosec" ,
171+ "type: ignore" ,
172+ "pyright:" ,
173+ "pylint:" ,
174+ "ruff:" ,
175+ "flake8:" ,
176+ ] ;
177+
178+ /// Returns true if `line` contains a comment that begins with a known
179+ /// non-pyrefly linter pragma. Excludes pyrefly's own comments — those are
180+ /// handled by the existing merge path.
181+ fn has_foreign_linter_pragma ( line : & str ) -> bool {
182+ let Some ( start) = find_comment_start_in_line ( line) else {
183+ return false ;
184+ } ;
185+ // Skip the `#` and any whitespace.
186+ let body = line[ start + 1 ..] . trim_start ( ) ;
187+ let body_lower = body. to_lowercase ( ) ;
188+ // Exclude pyrefly comments — handled elsewhere.
189+ if body_lower. starts_with ( "pyrefly:" )
190+ || body_lower. starts_with ( "pyre:" )
191+ || body_lower. starts_with ( "pyre-" )
192+ {
193+ return false ;
194+ }
195+ FOREIGN_LINTER_PRAGMAS . iter ( ) . any ( |p| body_lower. starts_with ( p) )
196+ }
197+
163198/// Extracts error codes from an existing pyrefly ignore comment.
164199/// Returns Some(Vec<String>) if the line contains a valid ignore comment, None otherwise.
165200/// Uses string-aware parsing to avoid matching inside string literals.
@@ -380,22 +415,37 @@ fn add_suppressions(
380415 let suppression_below =
381416 idx + 1 < lines. len ( ) && lines_to_skip. contains ( & ( idx + 1 ) ) ;
382417
383- if !suppression_below {
384- // Add suppression line above (normal case)
385- buf. push_str ( get_indentation ( line) ) ;
418+ // Bug fix #3373: don't insert between a foreign linter pragma and its target
419+ // line — that would silently disable the foreign pragma. Append inline instead.
420+ // (Line-length overruns are intentional; autoformatters handle them.)
421+ let force_inline = !suppression_below
422+ && idx > 0
423+ && !lines_to_skip. contains ( & ( idx - 1 ) )
424+ && has_foreign_linter_pragma ( lines[ idx - 1 ] ) ;
425+
426+ if force_inline {
427+ buf. push_str ( line) ;
428+ buf. push_str ( " " ) ;
386429 buf. push_str ( error_comment) ;
387430 buf. push_str ( line_ending) ;
388- }
389-
390- // Write the current line as-is
391- buf. push_str ( line) ;
392- buf. push_str ( line_ending) ;
431+ } else {
432+ if !suppression_below {
433+ // Add suppression line above (normal case)
434+ buf. push_str ( get_indentation ( line) ) ;
435+ buf. push_str ( error_comment) ;
436+ buf. push_str ( line_ending) ;
437+ }
393438
394- if suppression_below {
395- // Add suppression line below
396- buf. push_str ( get_indentation ( lines[ idx + 1 ] ) ) ;
397- buf. push_str ( error_comment) ;
439+ // Write the current line as-is
440+ buf. push_str ( line) ;
398441 buf. push_str ( line_ending) ;
442+
443+ if suppression_below {
444+ // Add suppression line below
445+ buf. push_str ( get_indentation ( lines[ idx + 1 ] ) ) ;
446+ buf. push_str ( error_comment) ;
447+ buf. push_str ( line_ending) ;
448+ }
399449 }
400450 }
401451 } else {
@@ -2049,4 +2099,110 @@ def foo(x: tuple) -> None:
20492099"# ,
20502100 ) ;
20512101 }
2102+
2103+ #[ test]
2104+ fn foreign_pragma_detection ( ) {
2105+ assert ! ( has_foreign_linter_pragma( "# noqa: F821" ) ) ;
2106+ assert ! ( has_foreign_linter_pragma( " # noqa" ) ) ;
2107+ assert ! ( has_foreign_linter_pragma( "# nosemgrep: rules.foo" ) ) ;
2108+ assert ! ( has_foreign_linter_pragma( "x = 1 # noqa" ) ) ; // trailing comment counts
2109+ assert ! ( has_foreign_linter_pragma( "# type: ignore" ) ) ;
2110+ assert ! ( has_foreign_linter_pragma( "# pyright: ignore[reportFoo]" ) ) ;
2111+ assert ! ( has_foreign_linter_pragma( "# pylint: disable=W" ) ) ;
2112+ assert ! ( !has_foreign_linter_pragma( "# pyrefly: ignore [bad-return]" ) ) ;
2113+ assert ! ( !has_foreign_linter_pragma( "# pyre-fixme[1]" ) ) ;
2114+ assert ! ( !has_foreign_linter_pragma( "# regular comment" ) ) ;
2115+ assert ! ( !has_foreign_linter_pragma( "x = '# noqa'" ) ) ; // string, not comment
2116+ assert ! ( !has_foreign_linter_pragma( "" ) ) ;
2117+ }
2118+
2119+ #[ test]
2120+ fn test_add_suppressions_preserves_noqa_pragma ( ) {
2121+ // Bug #3373: # noqa above the target must not be orphaned.
2122+ assert_suppress_errors (
2123+ r#"
2124+ def foo():
2125+ # noqa: F821
2126+ return undefined_variable
2127+ "# ,
2128+ r#"
2129+ def foo():
2130+ # noqa: F821
2131+ return undefined_variable # pyrefly: ignore [unknown-name]
2132+ "# ,
2133+ ) ;
2134+ }
2135+
2136+ #[ test]
2137+ fn test_add_suppressions_preserves_nosemgrep_pragma ( ) {
2138+ // The exact scenario from issue #3373.
2139+ assert_suppress_errors (
2140+ r#"
2141+ import subprocess
2142+ def run_command(cmd):
2143+ # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
2144+ result = subprocess.run(cmd, shell=True, foo="bar")
2145+ return result.returncode
2146+ "# ,
2147+ r#"
2148+ import subprocess
2149+ def run_command(cmd):
2150+ # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
2151+ result = subprocess.run(cmd, shell=True, foo="bar") # pyrefly: ignore [no-matching-overload]
2152+ return result.returncode
2153+ "# ,
2154+ ) ;
2155+ }
2156+
2157+ #[ test]
2158+ fn test_add_suppressions_preserves_pyright_pragma ( ) {
2159+ assert_suppress_errors (
2160+ r#"
2161+ def foo():
2162+ # pyright: ignore[reportFoo]
2163+ bar()
2164+ "# ,
2165+ r#"
2166+ def foo():
2167+ # pyright: ignore[reportFoo]
2168+ bar() # pyrefly: ignore [unknown-name]
2169+ "# ,
2170+ ) ;
2171+ }
2172+
2173+ #[ test]
2174+ fn test_add_suppressions_plain_comment_above_still_inserts_above ( ) {
2175+ // Sanity: a plain comment is NOT a foreign pragma, behavior unchanged.
2176+ assert_suppress_errors (
2177+ r#"
2178+ def foo() -> int:
2179+ # comment
2180+ return ""
2181+ "# ,
2182+ r#"
2183+ def foo() -> int:
2184+ # comment
2185+ # pyrefly: ignore [bad-return]
2186+ return ""
2187+ "# ,
2188+ ) ;
2189+ }
2190+
2191+ #[ test]
2192+ fn test_add_suppressions_pragma_string_not_comment ( ) {
2193+ // # noqa appearing inside a string literal must NOT trigger inline behavior.
2194+ assert_suppress_errors (
2195+ r##"
2196+ def foo() -> int:
2197+ msg = "# noqa: do not match"
2198+ return ""
2199+ "## ,
2200+ r##"
2201+ def foo() -> int:
2202+ msg = "# noqa: do not match"
2203+ # pyrefly: ignore [bad-return]
2204+ return ""
2205+ "## ,
2206+ ) ;
2207+ }
20522208}
0 commit comments