Fix RUF100 to detect unused file-level noqa directives with specific codes (#17042) (#17061)

Closes #17042

## Summary
This PR fixes the issue outlined in #17042 where RUF100 (unused-noqa)
fails to detect unused file-level noqa directives (`# ruff: noqa` or `#
ruff: noqa: {code}`).

The issue stems from two underlying causes:

1. For blanket file-level directives (`# ruff: noqa`), there's a
circular dependency: the directive exempts all rules including RUF100
itself, which prevents checking for usage. This isn't changed by this
PR. I would argue it is intendend behavior - a blanket `# ruff: noqa`
directive should exempt all rules including RUF100 itself.

2. For code-specific file-level directives (e.g. `# ruff: noqa: F841`),
the handling was missing in the `check_noqa` function. This is added in
this PR.

## Notes
- For file-level directives, the `matches` array is pre-populated with
the specified codes during parsing, unlike line-level directives which
only populate their `matches` array when actually suppressing
diagnostics. This difference requires the somewhat clunky handling of
both cases. I would appreciate guidance on a cleaner design :)

- A more fundamental solution would be to change how file-level
directives initialize the `matches` array in
`FileNoqaDirectives::extract()`, but that requires more substantial
changes as it breaks existing functionality. I suspect discussions in
#16483 are relevant for this.

## Test Plan
- Local verification
- Added a test case and fixture
This commit is contained in:
Max Mynter
2025-04-07 16:21:52 +02:00
committed by GitHub
parent 83a97235ae
commit f1d4ba32cc
6 changed files with 134 additions and 14 deletions

View File

@@ -0,0 +1 @@
# ruff: noqa: F841 -- intentional unused file directive; will be removed

View File

@@ -0,0 +1,8 @@
# flake8: noqa: F841, E501 -- used followed by unused code
# ruff: noqa: E701, F541 -- unused followed by used code
def a():
# Violating noqa'd F841 (unused) and F541 (no-placeholder f-string)
x = f"Hello, world"
return 6

View File

@@ -110,10 +110,29 @@ pub(crate) fn check_noqa(
&& !exemption.includes(Rule::UnusedNOQA)
&& !per_file_ignores.contains(Rule::UnusedNOQA)
{
for line in noqa_directives.lines() {
match &line.directive {
let directives: Vec<_> = if settings.preview.is_enabled() {
noqa_directives
.lines()
.iter()
.map(|line| (&line.directive, &line.matches, false))
.chain(
file_noqa_directives
.lines()
.iter()
.map(|line| (&line.parsed_file_exemption, &line.matches, true)),
)
.collect()
} else {
noqa_directives
.lines()
.iter()
.map(|line| (&line.directive, &line.matches, false))
.collect()
};
for (directive, matches, is_file_level) in directives {
match directive {
Directive::All(directive) => {
if line.matches.is_empty() {
if matches.is_empty() {
let edit = delete_comment(directive.range(), locator);
let mut diagnostic =
Diagnostic::new(UnusedNOQA { codes: None }, directive.range());
@@ -137,17 +156,21 @@ pub(crate) fn check_noqa(
break;
}
if !seen_codes.insert(original_code) {
duplicated_codes.push(original_code);
} else if line.matches.iter().any(|match_| *match_ == code)
|| settings
if seen_codes.insert(original_code) {
let is_code_used = if is_file_level {
diagnostics
.iter()
.any(|diag| diag.kind.rule().noqa_code() == code)
} else {
matches.iter().any(|match_| *match_ == code)
} || settings
.external
.iter()
.any(|external| code.starts_with(external))
{
valid_codes.push(original_code);
} else {
if let Ok(rule) = Rule::from_code(code) {
.any(|external| code.starts_with(external));
if is_code_used {
valid_codes.push(original_code);
} else if let Ok(rule) = Rule::from_code(code) {
if settings.rules.enabled(rule) {
unmatched_codes.push(original_code);
} else {
@@ -156,6 +179,8 @@ pub(crate) fn check_noqa(
} else {
unknown_codes.push(original_code);
}
} else {
duplicated_codes.push(original_code);
}
}
@@ -171,8 +196,18 @@ pub(crate) fn check_noqa(
let edit = if valid_codes.is_empty() {
delete_comment(directive.range(), locator)
} else {
let original_text = locator.slice(directive.range());
let prefix = if is_file_level {
if original_text.contains("flake8") {
"# flake8: noqa: "
} else {
"# ruff: noqa: "
}
} else {
"# noqa: "
};
Edit::range_replacement(
format!("# noqa: {}", valid_codes.join(", ")),
format!("{}{}", prefix, valid_codes.join(", ")),
directive.range(),
)
};

View File

@@ -315,6 +315,37 @@ mod tests {
Ok(())
}
#[test]
fn ruff_noqa_filedirective_unused() -> Result<()> {
let diagnostics = test_path(
Path::new("ruff/RUF100_6.py"),
&settings::LinterSettings {
preview: PreviewMode::Enabled,
..settings::LinterSettings::for_rules(vec![Rule::UnusedNOQA])
},
)?;
assert_messages!(diagnostics);
Ok(())
}
#[test]
fn ruff_noqa_filedirective_unused_last_of_many() -> Result<()> {
let diagnostics = test_path(
Path::new("ruff/RUF100_7.py"),
&settings::LinterSettings {
preview: PreviewMode::Enabled,
..settings::LinterSettings::for_rules(vec![
Rule::UnusedNOQA,
Rule::FStringMissingPlaceholders,
Rule::LineTooLong,
Rule::UnusedVariable,
])
},
)?;
assert_messages!(diagnostics);
Ok(())
}
#[test]
fn invalid_rule_code_external_rules() -> Result<()> {
let diagnostics = test_path(
@@ -324,7 +355,6 @@ mod tests {
..settings::LinterSettings::for_rule(Rule::InvalidRuleCode)
},
)?;
assert_messages!(diagnostics);
Ok(())
}

View File

@@ -0,0 +1,13 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF100_6.py:1:1: RUF100 [*] Unused `noqa` directive (non-enabled: `F841`)
|
1 | # ruff: noqa: F841 -- intentional unused file directive; will be removed
| ^^^^^^^^^^^^^^^^^^ RUF100
|
= help: Remove unused `noqa` directive
Safe fix
1 |-# ruff: noqa: F841 -- intentional unused file directive; will be removed
1 |+

View File

@@ -0,0 +1,33 @@
---
source: crates/ruff_linter/src/rules/ruff/mod.rs
---
RUF100_7.py:1:1: RUF100 [*] Unused `noqa` directive (unused: `E501`)
|
1 | # flake8: noqa: F841, E501 -- used followed by unused code
| ^^^^^^^^^^^^^^^^^^^^^^^^^^ RUF100
2 | # ruff: noqa: E701, F541 -- unused followed by used code
|
= help: Remove unused `noqa` directive
Safe fix
1 |-# flake8: noqa: F841, E501 -- used followed by unused code
1 |+# flake8: noqa: F841 -- used followed by unused code
2 2 | # ruff: noqa: E701, F541 -- unused followed by used code
3 3 |
4 4 |
RUF100_7.py:2:1: RUF100 [*] Unused `noqa` directive (non-enabled: `E701`)
|
1 | # flake8: noqa: F841, E501 -- used followed by unused code
2 | # ruff: noqa: E701, F541 -- unused followed by used code
| ^^^^^^^^^^^^^^^^^^^^^^^^ RUF100
|
= help: Remove unused `noqa` directive
Safe fix
1 1 | # flake8: noqa: F841, E501 -- used followed by unused code
2 |-# ruff: noqa: E701, F541 -- unused followed by used code
2 |+# ruff: noqa: F541 -- unused followed by used code
3 3 |
4 4 |
5 5 | def a():