Skip to content

Commit c585462

Browse files
committed
feat(detect): add declaration-scoped # noqa: codeclone[dead-code] suppressions (parser, symbol binding, final filtering) with tests and docs; update html-report UI
1 parent 3d8e372 commit c585462

23 files changed

Lines changed: 1310 additions & 292 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ ahead of the final `2.0.0` release.
118118
production symbols used only in tests are still reported as dead-code candidates.
119119
- Dead-code liveness now uses exact canonical qualname references (including import-alias and module-alias usage)
120120
before fallback local-name checks, reducing false positives on re-export and alias wiring.
121+
- Added declaration-scoped inline suppressions for accepted dead-code findings:
122+
- `# noqa: codeclone[dead-code]` on `def`, `async def`, or `class`
123+
- supports both previous-line and end-of-line forms on declaration lines
124+
- suppression is target-scoped (does not cascade to unrelated symbols)
125+
- Added deterministic suppression parser/binder (`codeclone/suppressions.py`) and integrated suppression metadata into
126+
dead-code candidate processing and cache payloads (backward-compatible decode for legacy cache rows).
121127
- Refactored `scanner.iter_py_files` into deterministic helpers without semantic changes, reducing method complexity and
122128
keeping metrics-gate parity with the baseline.
123129

README.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,27 @@ codeclone . --fail-cycles --fail-dead-code
8080
codeclone . --fail-on-new-metrics
8181
```
8282

83+
### Inline Suppressions For Known FP
84+
85+
Use local declaration-level suppressions when a finding is accepted by design
86+
(for example runtime callbacks invoked by a framework):
87+
88+
```python
89+
# noqa: codeclone[dead-code]
90+
def handle_exception(exc: Exception) -> None:
91+
...
92+
93+
class Middleware: # noqa: codeclone[dead-code]
94+
...
95+
```
96+
97+
Rules:
98+
99+
- supports `def`, `async def`, and `class`
100+
- supports previous-line and end-of-line forms on declaration lines
101+
- requires explicit rule list: `codeclone[...]`
102+
- does not provide file-level/global ignores
103+
83104
### Pre-commit
84105

85106
```yaml
@@ -154,6 +175,9 @@ Structural findings include:
154175
- `clone_guard_exit_divergence`
155176
- `clone_cohort_drift`
156177

178+
Dead-code detection is intentionally deterministic and static. Dynamic/runtime false positives are resolved
179+
via explicit inline suppressions, not via broad heuristics or implicit framework-specific guesses.
180+
157181
<details>
158182
<summary>JSON report shape (v2.1)</summary>
159183

@@ -265,7 +289,7 @@ Architecture: [`docs/architecture.md`](docs/architecture.md) · CFG semantics: [
265289
| Docker benchmark contract | [`docs/book/18-benchmarking.md`](docs/book/18-benchmarking.md) |
266290
| Determinism | [`docs/book/12-determinism.md`](docs/book/12-determinism.md) |
267291

268-
## * Benchmarking
292+
## * Benchmarking
269293

270294
<details>
271295
<summary>Reproducible Docker Benchmark</summary>

codeclone/cache.py

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ class ModuleDepDict(TypedDict):
9292
line: int
9393

9494

95-
class DeadCandidateDict(TypedDict):
95+
class DeadCandidateDictBase(TypedDict):
9696
qualname: str
9797
local_name: str
9898
filepath: str
@@ -101,6 +101,10 @@ class DeadCandidateDict(TypedDict):
101101
kind: str
102102

103103

104+
class DeadCandidateDict(DeadCandidateDictBase, total=False):
105+
suppressed_rules: list[str]
106+
107+
104108
class StructuralFindingOccurrenceDict(TypedDict):
105109
qualname: str
106110
start: int
@@ -1041,14 +1045,17 @@ def _dead_candidate_dict_from_model(
10411045
candidate: DeadCandidate,
10421046
filepath: str,
10431047
) -> DeadCandidateDict:
1044-
return DeadCandidateDict(
1048+
result = DeadCandidateDict(
10451049
qualname=candidate.qualname,
10461050
local_name=candidate.local_name,
10471051
filepath=filepath,
10481052
start_line=candidate.start_line,
10491053
end_line=candidate.end_line,
10501054
kind=candidate.kind,
10511055
)
1056+
if candidate.suppressed_rules:
1057+
result["suppressed_rules"] = sorted(set(candidate.suppressed_rules))
1058+
return result
10521059

10531060

10541061
def _structural_occurrence_dict_from_model(
@@ -1203,14 +1210,32 @@ def _canonicalize_cache_entry(entry: CacheEntry) -> CacheEntry:
12031210
item["line"],
12041211
),
12051212
)
1213+
dead_candidates_normalized: list[DeadCandidateDict] = []
1214+
for candidate in entry["dead_candidates"]:
1215+
suppressed_rules = candidate.get("suppressed_rules", [])
1216+
normalized_candidate = DeadCandidateDict(
1217+
qualname=candidate["qualname"],
1218+
local_name=candidate["local_name"],
1219+
filepath=candidate["filepath"],
1220+
start_line=candidate["start_line"],
1221+
end_line=candidate["end_line"],
1222+
kind=candidate["kind"],
1223+
)
1224+
if _is_string_list(suppressed_rules):
1225+
normalized_rules = sorted(set(suppressed_rules))
1226+
if normalized_rules:
1227+
normalized_candidate["suppressed_rules"] = normalized_rules
1228+
dead_candidates_normalized.append(normalized_candidate)
1229+
12061230
dead_candidates_sorted = sorted(
1207-
entry["dead_candidates"],
1231+
dead_candidates_normalized,
12081232
key=lambda item: (
12091233
item["start_line"],
12101234
item["end_line"],
12111235
item["qualname"],
12121236
item["local_name"],
12131237
item["kind"],
1238+
tuple(item.get("suppressed_rules", [])),
12141239
),
12151240
)
12161241

@@ -1803,13 +1828,19 @@ def _decode_wire_dead_candidate(
18031828
filepath: str,
18041829
) -> DeadCandidateDict | None:
18051830
row = _as_list(value)
1806-
if row is None or len(row) != 5:
1831+
if row is None or len(row) not in {5, 6}:
18071832
return None
18081833
qualname = _as_str(row[0])
18091834
local_name = _as_str(row[1])
18101835
start_line = _as_int(row[2])
18111836
end_line = _as_int(row[3])
18121837
kind = _as_str(row[4])
1838+
suppressed_rules: list[str] | None = []
1839+
if len(row) == 6:
1840+
raw_rules = _as_list(row[5])
1841+
if raw_rules is None or not all(isinstance(rule, str) for rule in raw_rules):
1842+
return None
1843+
suppressed_rules = sorted({str(rule) for rule in raw_rules if str(rule)})
18131844
if (
18141845
qualname is None
18151846
or local_name is None
@@ -1818,14 +1849,17 @@ def _decode_wire_dead_candidate(
18181849
or kind is None
18191850
):
18201851
return None
1821-
return DeadCandidateDict(
1852+
decoded = DeadCandidateDict(
18221853
qualname=qualname,
18231854
local_name=local_name,
18241855
filepath=filepath,
18251856
start_line=start_line,
18261857
end_line=end_line,
18271858
kind=kind,
18281859
)
1860+
if suppressed_rules:
1861+
decoded["suppressed_rules"] = suppressed_rules
1862+
return decoded
18291863

18301864

18311865
def _encode_wire_file_entry(entry: CacheEntry) -> dict[str, object]:
@@ -1980,16 +2014,22 @@ def _encode_wire_file_entry(entry: CacheEntry) -> dict[str, object]:
19802014
if dead_candidates:
19812015
# Dead candidates are stored inside a per-file cache entry, so the
19822016
# filepath is implicit and does not need to be repeated in every row.
1983-
wire["dc"] = [
1984-
[
2017+
encoded_dead_candidates: list[list[object]] = []
2018+
for candidate in dead_candidates:
2019+
encoded = [
19852020
candidate["qualname"],
19862021
candidate["local_name"],
19872022
candidate["start_line"],
19882023
candidate["end_line"],
19892024
candidate["kind"],
19902025
]
1991-
for candidate in dead_candidates
1992-
]
2026+
suppressed_rules = candidate.get("suppressed_rules", [])
2027+
if _is_string_list(suppressed_rules):
2028+
normalized_rules = sorted(set(suppressed_rules))
2029+
if normalized_rules:
2030+
encoded.append(normalized_rules)
2031+
encoded_dead_candidates.append(encoded)
2032+
wire["dc"] = encoded_dead_candidates
19932033

19942034
if entry["referenced_names"]:
19952035
wire["rn"] = sorted(set(entry["referenced_names"]))
@@ -2135,11 +2175,16 @@ def _is_module_dep_dict(value: object) -> bool:
21352175
def _is_dead_candidate_dict(value: object) -> bool:
21362176
if not isinstance(value, dict):
21372177
return False
2138-
return _has_typed_fields(
2178+
if not _has_typed_fields(
21392179
value,
21402180
string_keys=("qualname", "local_name", "filepath", "kind"),
21412181
int_keys=("start_line", "end_line"),
2142-
)
2182+
):
2183+
return False
2184+
suppressed_rules = value.get("suppressed_rules")
2185+
if suppressed_rules is None:
2186+
return True
2187+
return _is_string_list(suppressed_rules)
21432188

21442189

21452190
def _is_string_list(value: object) -> bool:

codeclone/extractor.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,18 @@
4242
)
4343
from .paths import is_test_filepath
4444
from .structural_findings import scan_function_structure
45+
from .suppressions import (
46+
DeclarationTarget,
47+
bind_suppressions_to_declarations,
48+
build_suppression_index,
49+
extract_noqa_directives,
50+
suppression_target_key,
51+
)
4552

4653
if TYPE_CHECKING:
47-
from collections.abc import Iterator
54+
from collections.abc import Iterator, Mapping
55+
56+
from .suppressions import SuppressionTargetKey
4857

4958
__all__ = [
5059
"Unit",
@@ -472,6 +481,8 @@ def _collect_dead_candidates(
472481
protocol_module_aliases: frozenset[str] = frozenset(
473482
{"typing", "typing_extensions"}
474483
),
484+
suppression_rules_by_target: Mapping[SuppressionTargetKey, tuple[str, ...]]
485+
| None = None,
475486
) -> tuple[DeadCandidate, ...]:
476487
protocol_class_qualnames = {
477488
class_qualname
@@ -484,6 +495,9 @@ def _collect_dead_candidates(
484495
}
485496

486497
candidates: list[DeadCandidate] = []
498+
suppression_index = (
499+
suppression_rules_by_target if suppression_rules_by_target is not None else {}
500+
)
487501
for local_name, node in collector.units:
488502
start = int(getattr(node, "lineno", 0))
489503
end = int(getattr(node, "end_lineno", 0))
@@ -506,6 +520,16 @@ def _collect_dead_candidates(
506520
start_line=start,
507521
end_line=end,
508522
kind=kind,
523+
suppressed_rules=suppression_index.get(
524+
suppression_target_key(
525+
filepath=filepath,
526+
qualname=f"{module_name}:{local_name}",
527+
start_line=start,
528+
end_line=end,
529+
kind=kind,
530+
),
531+
(),
532+
),
509533
)
510534
)
511535

@@ -522,6 +546,16 @@ def _collect_dead_candidates(
522546
start_line=start,
523547
end_line=end,
524548
kind="class",
549+
suppressed_rules=suppression_index.get(
550+
suppression_target_key(
551+
filepath=filepath,
552+
qualname=f"{module_name}:{class_qualname}",
553+
start_line=start,
554+
end_line=end,
555+
kind="class",
556+
),
557+
(),
558+
),
525559
)
526560
)
527561

@@ -538,6 +572,61 @@ def _collect_dead_candidates(
538572
)
539573

540574

575+
def _collect_declaration_targets(
576+
*,
577+
filepath: str,
578+
module_name: str,
579+
collector: _QualnameCollector,
580+
) -> tuple[DeclarationTarget, ...]:
581+
declarations: list[DeclarationTarget] = []
582+
583+
for local_name, node in collector.units:
584+
start = int(getattr(node, "lineno", 0))
585+
end = int(getattr(node, "end_lineno", 0))
586+
if start <= 0 or end <= 0:
587+
continue
588+
kind: Literal["function", "method"] = (
589+
"method" if "." in local_name else "function"
590+
)
591+
declarations.append(
592+
DeclarationTarget(
593+
filepath=filepath,
594+
qualname=f"{module_name}:{local_name}",
595+
start_line=start,
596+
end_line=end,
597+
kind=kind,
598+
)
599+
)
600+
601+
for class_qualname, class_node in collector.class_nodes:
602+
start = int(getattr(class_node, "lineno", 0))
603+
end = int(getattr(class_node, "end_lineno", 0))
604+
if start <= 0 or end <= 0:
605+
continue
606+
declarations.append(
607+
DeclarationTarget(
608+
filepath=filepath,
609+
qualname=f"{module_name}:{class_qualname}",
610+
start_line=start,
611+
end_line=end,
612+
kind="class",
613+
)
614+
)
615+
616+
return tuple(
617+
sorted(
618+
declarations,
619+
key=lambda item: (
620+
item.filepath,
621+
item.start_line,
622+
item.end_line,
623+
item.qualname,
624+
item.kind,
625+
),
626+
)
627+
)
628+
629+
541630
# =========================
542631
# Public API
543632
# =========================
@@ -582,6 +671,17 @@ def extract_units_and_stats_from_source(
582671
collector=collector,
583672
collect_referenced_names=not is_test_file,
584673
)
674+
noqa_directives = extract_noqa_directives(source)
675+
declaration_targets = _collect_declaration_targets(
676+
filepath=filepath,
677+
module_name=module_name,
678+
collector=collector,
679+
)
680+
suppression_bindings = bind_suppressions_to_declarations(
681+
directives=noqa_directives,
682+
declarations=declaration_targets,
683+
)
684+
suppression_index = build_suppression_index(suppression_bindings)
585685
protocol_symbol_aliases, protocol_module_aliases = _collect_protocol_aliases(tree)
586686
class_names = frozenset(class_node.name for _, class_node in collector.class_nodes)
587687
module_import_names = set(import_names)
@@ -721,6 +821,7 @@ def extract_units_and_stats_from_source(
721821
collector=collector,
722822
protocol_symbol_aliases=protocol_symbol_aliases,
723823
protocol_module_aliases=protocol_module_aliases,
824+
suppression_rules_by_target=suppression_index,
724825
)
725826

726827
sorted_class_metrics = tuple(

0 commit comments

Comments
 (0)