Skip to content

Commit ef4617a

Browse files
committed
suppress: avoid orphaning foreign linter pragmas
When the line above a target already contains a non-pyrefly linter pragma (noqa, nosemgrep, pyright:, pylint:, etc.), `pyrefly suppress` was inserting a `# pyrefly: ignore [...]` line between them, which silently disabled the foreign pragma. Detect that case and append inline at the end of the target line instead. Fixes #3373
1 parent b22d424 commit ef4617a

2 files changed

Lines changed: 169 additions & 12 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyrefly/lib/error/suppress.rs

Lines changed: 168 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)