Skip to content

Commit 2fa06fd

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

5 files changed

Lines changed: 351 additions & 1 deletion

File tree

codeclone/extractor.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
from __future__ import annotations
55

66
import ast
7+
import io
78
import math
89
import os
910
import signal
11+
import tokenize
1012
from contextlib import contextmanager
1113
from hashlib import sha1 as _sha1
1214
from typing import TYPE_CHECKING, Literal
@@ -152,6 +154,98 @@ def _stmt_count(node: ast.AST) -> int:
152154
return len(body) if isinstance(body, list) else 0
153155

154156

157+
def _source_tokens(source: str) -> tuple[tokenize.TokenInfo, ...]:
158+
try:
159+
return tuple(tokenize.generate_tokens(io.StringIO(source).readline))
160+
except tokenize.TokenError:
161+
return ()
162+
163+
164+
def _declaration_token_name(node: ast.AST) -> str:
165+
if isinstance(node, ast.ClassDef):
166+
return "class"
167+
if isinstance(node, ast.AsyncFunctionDef):
168+
return "async"
169+
return "def"
170+
171+
172+
def _declaration_token_index(
173+
*,
174+
source_tokens: tuple[tokenize.TokenInfo, ...],
175+
start_line: int,
176+
start_col: int,
177+
declaration_token: str,
178+
) -> int | None:
179+
for idx, token in enumerate(source_tokens):
180+
if token.start != (start_line, start_col):
181+
continue
182+
if token.type == tokenize.NAME and token.string == declaration_token:
183+
return idx
184+
return None
185+
186+
187+
def _scan_declaration_colon_line(
188+
*,
189+
source_tokens: tuple[tokenize.TokenInfo, ...],
190+
start_index: int,
191+
) -> int | None:
192+
nesting = 0
193+
for token in source_tokens[start_index + 1 :]:
194+
if token.type == tokenize.OP:
195+
if token.string in "([{":
196+
nesting += 1
197+
continue
198+
if token.string in ")]}":
199+
if nesting > 0:
200+
nesting -= 1
201+
continue
202+
if token.string == ":" and nesting == 0:
203+
return token.start[0]
204+
if token.type == tokenize.NEWLINE and nesting == 0:
205+
return None
206+
return None
207+
208+
209+
def _fallback_declaration_end_line(node: ast.AST, *, start_line: int) -> int:
210+
body = getattr(node, "body", None)
211+
if not isinstance(body, list) or not body:
212+
return start_line
213+
214+
first_body_line = int(getattr(body[0], "lineno", 0))
215+
if first_body_line <= 0 or first_body_line == start_line:
216+
return start_line
217+
return max(start_line, first_body_line - 1)
218+
219+
220+
def _declaration_end_line(
221+
node: ast.AST,
222+
*,
223+
source_tokens: tuple[tokenize.TokenInfo, ...],
224+
) -> int:
225+
start_line = int(getattr(node, "lineno", 0))
226+
start_col = int(getattr(node, "col_offset", 0))
227+
if start_line <= 0:
228+
return 0
229+
230+
declaration_token = _declaration_token_name(node)
231+
start_index = _declaration_token_index(
232+
source_tokens=source_tokens,
233+
start_line=start_line,
234+
start_col=start_col,
235+
declaration_token=declaration_token,
236+
)
237+
if start_index is None:
238+
return _fallback_declaration_end_line(node, start_line=start_line)
239+
240+
colon_line = _scan_declaration_colon_line(
241+
source_tokens=source_tokens,
242+
start_index=start_index,
243+
)
244+
if colon_line is not None:
245+
return colon_line
246+
return _fallback_declaration_end_line(node, start_line=start_line)
247+
248+
155249
class _QualnameCollector(ast.NodeVisitor):
156250
__slots__ = (
157251
"class_count",
@@ -577,6 +671,7 @@ def _collect_declaration_targets(
577671
filepath: str,
578672
module_name: str,
579673
collector: _QualnameCollector,
674+
source_tokens: tuple[tokenize.TokenInfo, ...],
580675
) -> tuple[DeclarationTarget, ...]:
581676
declarations: list[DeclarationTarget] = []
582677

@@ -585,6 +680,10 @@ def _collect_declaration_targets(
585680
end = int(getattr(node, "end_lineno", 0))
586681
if start <= 0 or end <= 0:
587682
continue
683+
declaration_end_line = _declaration_end_line(
684+
node,
685+
source_tokens=source_tokens,
686+
)
588687
kind: Literal["function", "method"] = (
589688
"method" if "." in local_name else "function"
590689
)
@@ -595,6 +694,7 @@ def _collect_declaration_targets(
595694
start_line=start,
596695
end_line=end,
597696
kind=kind,
697+
declaration_end_line=declaration_end_line,
598698
)
599699
)
600700

@@ -603,13 +703,18 @@ def _collect_declaration_targets(
603703
end = int(getattr(class_node, "end_lineno", 0))
604704
if start <= 0 or end <= 0:
605705
continue
706+
declaration_end_line = _declaration_end_line(
707+
class_node,
708+
source_tokens=source_tokens,
709+
)
606710
declarations.append(
607711
DeclarationTarget(
608712
filepath=filepath,
609713
qualname=f"{module_name}:{class_qualname}",
610714
start_line=start,
611715
end_line=end,
612716
kind="class",
717+
declaration_end_line=declaration_end_line,
613718
)
614719
)
615720

@@ -657,6 +762,7 @@ def extract_units_and_stats_from_source(
657762
collector = _QualnameCollector()
658763
collector.visit(tree)
659764
source_lines = source.splitlines()
765+
source_tokens = _source_tokens(source)
660766
source_line_count = len(source_lines)
661767

662768
is_test_file = is_test_filepath(filepath)
@@ -676,6 +782,7 @@ def extract_units_and_stats_from_source(
676782
filepath=filepath,
677783
module_name=module_name,
678784
collector=collector,
785+
source_tokens=source_tokens,
679786
)
680787
suppression_bindings = bind_suppressions_to_declarations(
681788
directives=suppression_directives,

codeclone/suppressions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class DeclarationTarget:
6363
start_line: int
6464
end_line: int
6565
kind: DeclarationKind
66+
declaration_end_line: int | None = None
6667

6768

6869
@dataclass(frozen=True, slots=True)
@@ -176,9 +177,10 @@ def bind_suppressions_to_declarations(
176177

177178
bindings: list[SuppressionBinding] = []
178179
for target in declarations:
180+
inline_binding_line = target.declaration_end_line or target.start_line
179181
bound_rules = _merge_rules(
180182
leading_rules_by_line.get(target.start_line - 1, ()),
181-
inline_rules_by_line.get(target.start_line, ()),
183+
inline_rules_by_line.get(inline_binding_line, ()),
182184
)
183185
if not bound_rules:
184186
continue

tests/test_cli_inprocess.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3803,6 +3803,71 @@ 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+
"""\
3814+
class Settings: # codeclone: ignore[dead-code]
3815+
@validator("field")
3816+
@classmethod
3817+
def validate_config_version(
3818+
cls,
3819+
value: str | None,
3820+
) -> str | None: # codeclone: ignore[dead-code]
3821+
return value
3822+
""",
3823+
)
3824+
json_out = tmp_path / "report.json"
3825+
cache_path = tmp_path / "cache.json"
3826+
3827+
_patch_parallel(monkeypatch)
3828+
_run_main(
3829+
monkeypatch,
3830+
[
3831+
str(tmp_path),
3832+
"--cache-path",
3833+
str(cache_path),
3834+
"--fail-dead-code",
3835+
"--no-progress",
3836+
],
3837+
)
3838+
3839+
cache_payload = json.loads(cache_path.read_text("utf-8"))
3840+
files_before = cache_payload["payload"]["files"]
3841+
assert all("sf" not in entry for entry in files_before.values())
3842+
3843+
_run_main(
3844+
monkeypatch,
3845+
[
3846+
str(tmp_path),
3847+
"--cache-path",
3848+
str(cache_path),
3849+
"--fail-dead-code",
3850+
"--json",
3851+
str(json_out),
3852+
"--no-progress",
3853+
],
3854+
)
3855+
payload = json.loads(json_out.read_text("utf-8"))
3856+
dead_code = payload["metrics"]["families"]["dead_code"]
3857+
assert dead_code["summary"] == {"total": 0, "high_confidence": 0, "suppressed": 2}
3858+
3859+
_run_main(
3860+
monkeypatch,
3861+
[
3862+
str(tmp_path),
3863+
"--cache-path",
3864+
str(cache_path),
3865+
"--fail-dead-code",
3866+
"--no-progress",
3867+
],
3868+
)
3869+
3870+
38063871
@pytest.mark.parametrize(
38073872
("reason", "expected"),
38083873
[

tests/test_extractor.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,57 @@ def foo():
6666
assert segments == []
6767

6868

69+
def test_source_tokens_returns_empty_on_tokenize_error() -> None:
70+
assert extractor._source_tokens('"""') == ()
71+
72+
73+
def test_declaration_token_index_returns_none_when_start_token_is_missing() -> None:
74+
tokens = extractor._source_tokens("value = 1\n")
75+
assert (
76+
extractor._declaration_token_index(
77+
source_tokens=tokens,
78+
start_line=1,
79+
start_col=0,
80+
declaration_token="def",
81+
)
82+
is None
83+
)
84+
85+
86+
def test_scan_declaration_colon_line_returns_none_when_header_is_incomplete() -> None:
87+
tokens = extractor._source_tokens("def broken\n")
88+
assert (
89+
extractor._scan_declaration_colon_line(
90+
source_tokens=tokens,
91+
start_index=0,
92+
)
93+
is None
94+
)
95+
96+
97+
def test_declaration_end_line_falls_back_without_tokens() -> None:
98+
node = ast.parse(
99+
"""
100+
class Demo:
101+
pass
102+
"""
103+
).body[0]
104+
assert isinstance(node, ast.ClassDef)
105+
assert extractor._declaration_end_line(node, source_tokens=()) == 2
106+
107+
108+
def test_declaration_end_line_returns_zero_for_invalid_start_line() -> None:
109+
node = ast.parse(
110+
"""
111+
def broken():
112+
return 1
113+
"""
114+
).body[0]
115+
assert isinstance(node, ast.FunctionDef)
116+
node.lineno = 0
117+
assert extractor._declaration_end_line(node, source_tokens=()) == 0
118+
119+
69120
def test_init_function_is_ignored_for_blocks() -> None:
70121
src = """
71122
class A:
@@ -675,6 +726,36 @@ def alive(self):
675726
assert tuple(item.qualname for item in dead) == ("pkg.mod:Service.alive",)
676727

677728

729+
def test_dead_code_binds_inline_suppression_on_multiline_decorated_method() -> None:
730+
src = """
731+
class Settings: # codeclone: ignore[dead-code]
732+
@validator("field")
733+
@classmethod
734+
def validate_config_version(
735+
cls,
736+
value: str | None,
737+
) -> str | None: # codeclone: ignore[dead-code]
738+
return value
739+
740+
def orphan(self) -> int:
741+
return 1
742+
"""
743+
_, _, _, _, file_metrics, _ = extractor.extract_units_and_stats_from_source(
744+
source=src,
745+
filepath="pkg/mod.py",
746+
module_name="pkg.mod",
747+
cfg=NormalizationConfig(),
748+
min_loc=1,
749+
min_stmt=1,
750+
)
751+
dead = find_unused(
752+
definitions=file_metrics.dead_candidates,
753+
referenced_names=file_metrics.referenced_names,
754+
referenced_qualnames=file_metrics.referenced_qualnames,
755+
)
756+
assert tuple(item.qualname for item in dead) == ("pkg.mod:Settings.orphan",)
757+
758+
678759
def test_collect_dead_candidates_and_extract_skip_classes_without_lineno(
679760
monkeypatch: pytest.MonkeyPatch,
680761
) -> None:

0 commit comments

Comments
 (0)