Skip to content

Commit 50901f5

Browse files
committed
fix(suppressions): bind multiline inline ignores to decorated declaration headers consistently
1 parent 2fa06fd commit 50901f5

4 files changed

Lines changed: 207 additions & 11 deletions

File tree

codeclone/suppressions.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,24 @@ def extract_suppression_directives(
158158
)
159159

160160

161+
def _declaration_inline_lines(target: DeclarationTarget) -> tuple[int, ...]:
162+
end_line = target.declaration_end_line or target.start_line
163+
if end_line <= 0 or end_line == target.start_line:
164+
return (target.start_line,)
165+
return (target.start_line, end_line)
166+
167+
168+
def _bound_inline_rules(
169+
*,
170+
target: DeclarationTarget,
171+
inline_rules_by_line: Mapping[int, tuple[str, ...]],
172+
) -> tuple[str, ...]:
173+
rules: tuple[str, ...] = ()
174+
for line_no in _declaration_inline_lines(target):
175+
rules = _merge_rules(rules, inline_rules_by_line.get(line_no, ()))
176+
return rules
177+
178+
161179
def bind_suppressions_to_declarations(
162180
*,
163181
directives: Sequence[SuppressionDirective],
@@ -177,10 +195,12 @@ def bind_suppressions_to_declarations(
177195

178196
bindings: list[SuppressionBinding] = []
179197
for target in declarations:
180-
inline_binding_line = target.declaration_end_line or target.start_line
181198
bound_rules = _merge_rules(
182199
leading_rules_by_line.get(target.start_line - 1, ()),
183-
inline_rules_by_line.get(inline_binding_line, ()),
200+
_bound_inline_rules(
201+
target=target,
202+
inline_rules_by_line=inline_rules_by_line,
203+
),
184204
)
185205
if not bound_rules:
186206
continue

tests/test_cli_inprocess.py

Lines changed: 45 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3803,14 +3803,11 @@ def fn(x):
38033803
assert any("sf" in entry for entry in files_after.values())
38043804

38053805

3806-
def test_cli_dead_code_suppression_is_stable_between_plain_and_json_runs(
3807-
tmp_path: Path,
3808-
monkeypatch: pytest.MonkeyPatch,
3809-
) -> None:
3810-
_write_python_module(
3811-
tmp_path,
3812-
"models.py",
3813-
"""\
3806+
@pytest.mark.parametrize(
3807+
("source", "suppressed_count"),
3808+
[
3809+
(
3810+
"""\
38143811
class Settings: # codeclone: ignore[dead-code]
38153812
@validator("field")
38163813
@classmethod
@@ -3820,6 +3817,41 @@ def validate_config_version(
38203817
) -> str | None: # codeclone: ignore[dead-code]
38213818
return value
38223819
""",
3820+
2,
3821+
),
3822+
(
3823+
"""\
3824+
class Settings: # codeclone: ignore[dead-code]
3825+
@field_validator("trusted_proxy_ips", "additional_telegram_ip_ranges")
3826+
@classmethod
3827+
def validate_trusted_proxy_ips( # codeclone: ignore[dead-code]
3828+
cls,
3829+
value: list[str] | None,
3830+
) -> list[str] | None:
3831+
return value
3832+
3833+
@model_validator(mode="before")
3834+
@classmethod
3835+
def migrate_config_if_needed( # codeclone: ignore[dead-code]
3836+
cls,
3837+
values: dict[str, object],
3838+
) -> dict[str, object]:
3839+
return values
3840+
""",
3841+
3,
3842+
),
3843+
],
3844+
)
3845+
def test_cli_dead_code_suppression_is_stable_between_plain_and_json_runs(
3846+
tmp_path: Path,
3847+
monkeypatch: pytest.MonkeyPatch,
3848+
source: str,
3849+
suppressed_count: int,
3850+
) -> None:
3851+
_write_python_module(
3852+
tmp_path,
3853+
"models.py",
3854+
source,
38233855
)
38243856
json_out = tmp_path / "report.json"
38253857
cache_path = tmp_path / "cache.json"
@@ -3854,7 +3886,11 @@ def validate_config_version(
38543886
)
38553887
payload = json.loads(json_out.read_text("utf-8"))
38563888
dead_code = payload["metrics"]["families"]["dead_code"]
3857-
assert dead_code["summary"] == {"total": 0, "high_confidence": 0, "suppressed": 2}
3889+
assert dead_code["summary"] == {
3890+
"total": 0,
3891+
"high_confidence": 0,
3892+
"suppressed": suppressed_count,
3893+
}
38583894

38593895
_run_main(
38603896
monkeypatch,

tests/test_extractor.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,44 @@ def orphan(self) -> int:
756756
assert tuple(item.qualname for item in dead) == ("pkg.mod:Settings.orphan",)
757757

758758

759+
def test_dead_code_binds_inline_suppression_on_multiline_header_start_line() -> None:
760+
src = """
761+
class Settings: # codeclone: ignore[dead-code]
762+
@field_validator("trusted_proxy_ips", "additional_telegram_ip_ranges")
763+
@classmethod
764+
def validate_trusted_proxy_ips( # codeclone: ignore[dead-code]
765+
cls,
766+
value: list[str] | None,
767+
) -> list[str] | None:
768+
return value
769+
770+
@model_validator(mode="before")
771+
@classmethod
772+
def migrate_config_if_needed( # codeclone: ignore[dead-code]
773+
cls,
774+
values: dict[str, object],
775+
) -> dict[str, object]:
776+
return values
777+
778+
def orphan(self) -> int:
779+
return 1
780+
"""
781+
_, _, _, _, file_metrics, _ = extractor.extract_units_and_stats_from_source(
782+
source=src,
783+
filepath="pkg/mod.py",
784+
module_name="pkg.mod",
785+
cfg=NormalizationConfig(),
786+
min_loc=1,
787+
min_stmt=1,
788+
)
789+
dead = find_unused(
790+
definitions=file_metrics.dead_candidates,
791+
referenced_names=file_metrics.referenced_names,
792+
referenced_qualnames=file_metrics.referenced_qualnames,
793+
)
794+
assert tuple(item.qualname for item in dead) == ("pkg.mod:Settings.orphan",)
795+
796+
759797
def test_collect_dead_candidates_and_extract_skip_classes_without_lineno(
760798
monkeypatch: pytest.MonkeyPatch,
761799
) -> None:

tests/test_suppressions.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,108 @@ def keep(
240240
)
241241

242242

243+
@pytest.mark.parametrize(
244+
("source", "declaration"),
245+
[
246+
(
247+
"""
248+
@decorator
249+
def keep( # codeclone: ignore[dead-code]
250+
arg: int,
251+
) -> int:
252+
return arg
253+
""".strip(),
254+
DeclarationTarget(
255+
filepath="pkg/mod.py",
256+
qualname="pkg.mod:keep",
257+
start_line=2,
258+
end_line=5,
259+
kind="function",
260+
declaration_end_line=4,
261+
),
262+
),
263+
(
264+
"""
265+
async def keep_async( # codeclone: ignore[dead-code]
266+
arg: int,
267+
) -> int:
268+
return arg
269+
""".strip(),
270+
DeclarationTarget(
271+
filepath="pkg/mod.py",
272+
qualname="pkg.mod:keep_async",
273+
start_line=1,
274+
end_line=4,
275+
kind="function",
276+
declaration_end_line=3,
277+
),
278+
),
279+
(
280+
"""
281+
class Demo( # codeclone: ignore[dead-code]
282+
Base,
283+
):
284+
pass
285+
""".strip(),
286+
DeclarationTarget(
287+
filepath="pkg/mod.py",
288+
qualname="pkg.mod:Demo",
289+
start_line=1,
290+
end_line=4,
291+
kind="class",
292+
declaration_end_line=3,
293+
),
294+
),
295+
],
296+
)
297+
def test_bind_suppressions_supports_inline_on_multiline_declaration_start_line(
298+
source: str,
299+
declaration: DeclarationTarget,
300+
) -> None:
301+
directives = extract_suppression_directives(source)
302+
bindings = bind_suppressions_to_declarations(
303+
directives=directives,
304+
declarations=(declaration,),
305+
)
306+
assert bindings == (
307+
SuppressionBinding(
308+
filepath=declaration.filepath,
309+
qualname=declaration.qualname,
310+
start_line=declaration.start_line,
311+
end_line=declaration.end_line,
312+
kind=declaration.kind,
313+
rules=("dead-code",),
314+
),
315+
)
316+
317+
318+
def test_bind_suppressions_ignores_inline_comment_on_middle_signature_line() -> None:
319+
source = """
320+
def keep(
321+
arg: int, # codeclone: ignore[dead-code]
322+
) -> int:
323+
return arg
324+
""".strip()
325+
directives = extract_suppression_directives(source)
326+
declarations = (
327+
DeclarationTarget(
328+
filepath="pkg/mod.py",
329+
qualname="pkg.mod:keep",
330+
start_line=1,
331+
end_line=4,
332+
kind="function",
333+
declaration_end_line=3,
334+
),
335+
)
336+
assert (
337+
bind_suppressions_to_declarations(
338+
directives=directives,
339+
declarations=declarations,
340+
)
341+
== ()
342+
)
343+
344+
243345
@pytest.mark.parametrize(
244346
("source", "declaration"),
245347
[

0 commit comments

Comments
 (0)