Repository navigation
Expand file tree
/
Copy pathdead_code.py
More file actions
955 lines (851 loc) · 39.5 KB
/
Copy pathdead_code.py
File metadata and controls
955 lines (851 loc) · 39.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
# Dead-code reachability engine. Roots (entry points, framework hooks,
# module-load callees, test code) expand over CALLS/REFERENCES edges;
# whatever is never reached is reported. Reachability runs client-side in
# Python: the per-root *BFS Cypher formulation is O(roots x graph) and hit
# memgraph's 600s timeout on big projects (django: 31k roots, 101k CALLS
# edges), whereas a multi-source walk over the fetched edges is linear and
# finishes in milliseconds.
import re
from collections import defaultdict
from collections.abc import Callable
from fnmatch import fnmatch
from . import constants as cs
from . import cypher_queries as cq
from .path_filters import matches_test_path
from .types_defs import (
DeadCodeConfig,
GraphQueryClient,
PropertyDict,
PropertyValue,
ResultRow,
ResultValue,
)
_MODULE = cs.NodeLabel.MODULE.value
_FUNCTION = cs.NodeLabel.FUNCTION.value
_METHOD = cs.NodeLabel.METHOD.value
_CLASS = cs.NodeLabel.CLASS.value
_CALLS = cs.RelationshipType.CALLS.value
_REFERENCES = cs.RelationshipType.REFERENCES.value
_INSTANTIATES = cs.RelationshipType.INSTANTIATES.value
_INHERITS = cs.RelationshipType.INHERITS.value
_DEFINES = cs.RelationshipType.DEFINES.value
_DEFINES_METHOD = cs.RelationshipType.DEFINES_METHOD.value
_OVERRIDES = cs.RelationshipType.OVERRIDES.value
_IMPLEMENTS = cs.RelationshipType.IMPLEMENTS.value
_JS_TS_EXTS = cs.TS_EXTENSIONS + cs.TSX_EXTENSIONS + cs.JS_EXTENSIONS
_NodeId = tuple[str, PropertyValue]
_RelTuple = tuple[str, PropertyValue, str, str, PropertyValue]
# The relationships that carry a resolution label; structural ones (INHERITS,
# IMPLEMENTS, OVERRIDES, DEFINES, DEFINES_METHOD) never do and must survive any
# confidence floor, or the walk loses the paths that keep overrides, protocol
# stubs and nested registered definitions alive (issue #1526).
_RESOLUTION_LABELLED_RELS = frozenset(
{
cs.RelationshipType.CALLS.value,
cs.RelationshipType.REFERENCES.value,
cs.RelationshipType.INSTANTIATES.value,
}
)
def _passes_floor(row: ResultRow, minimum: str | None) -> bool:
if str(row.get(cs.KEY_REL_TYPE) or "") not in _RESOLUTION_LABELLED_RELS:
return True
return resolution_at_least(row.get(cs.KEY_RESOLUTION), minimum)
def resolution_at_least(resolution: ResultValue | None, minimum: str | None) -> bool:
"""Whether an edge's confidence meets `--min-resolution` (issue #1526).
An edge without the label predates the label (or was emitted by a pass
that binds exactly) and ranks as exact; an unknown label never passes.
"""
if minimum is None:
return True
floor = cs.RESOLUTION_RANK.get(minimum, 0)
label = str(resolution) if isinstance(resolution, str) else cs.EdgeResolution.EXACT
return cs.RESOLUTION_RANK.get(label, 0) >= floor
def default_dead_code_config(
include_tests: bool,
include_classes: bool,
exclude_patterns: tuple[str, ...] = (),
) -> DeadCodeConfig:
return DeadCodeConfig(
include_tests=include_tests,
include_classes=include_classes,
root_decorators=frozenset(d.lower() for d in cs.DEFAULT_ROOT_DECORATORS),
entry_points=(),
test_patterns=tuple(cs.TEST_PATH_PATTERNS),
exclude_patterns=exclude_patterns,
)
def _norm_decorator(decorator: str) -> str:
# Drop '@' and any surrounding attribute brackets, take the text before
# '(', then the last dotted segment, lowercased -> `@app.route(...)` and a
# C# `[Route("x")]` both become `route`. Bracket-stripping keeps the
# normalization robust to whatever a highlight query captures.
cleaned = decorator.replace(cs.DECORATOR_AT, "").strip("[] ")
head = cleaned.split(cs.CHAR_PAREN_OPEN)[0]
return head.split(cs.SEPARATOR_DOT)[-1].strip("[]").lower()
def _is_dunder(name: str) -> bool:
# A __dunder__ method is invoked by the Python runtime (async with,
# iteration, operators), never by an explicit call the graph can see, so it
# is a reachability root, not dead code.
return (
len(name) > len(cs.PY_NAME_DUNDER) * 2
and name.startswith(cs.PY_NAME_DUNDER)
and name.endswith(cs.PY_NAME_DUNDER)
)
def _is_rust_runtime_root(name: str, is_method: bool, path: str) -> bool:
# A Rust `.rs` symbol the language/runtime invokes with no call site: `fn
# main()` (entry) or a trait-impl method (Display::fmt, Iterator::next).
# Name-scoped like Python dunders; trait methods must be methods.
if not path.endswith(cs.EXT_RS):
return False
# `main` is only the entry point as a receiverless `fn main()`; a method
# named main is not, so gate it to non-methods. Trait methods are the reverse.
if name in cs.RUST_ROOT_FUNCTION_NAMES:
return not is_method
return is_method and name in cs.RUST_TRAIT_METHOD_NAMES
def _has_rust_test_attribute(props: PropertyDict) -> bool:
decorators = props.get(cs.KEY_DECORATORS)
if not isinstance(decorators, list):
return False
for decorator in decorators:
head = str(decorator).strip("#[] ").split(cs.CHAR_PAREN_OPEN)[0]
# Attribute paths are token streams: `#[tokio :: test]` names the
# same attribute as `#[tokio::test]`, so drop internal whitespace
# before matching.
name = "".join(head.split())
if name in cs.RUST_TEST_ATTRIBUTE_NAMES or name.endswith(
cs.RUST_TEST_ATTRIBUTE_SUFFIX
):
return True
return False
def _is_rust_test_symbol(
props: PropertyDict, qn: str, path: str, rust_test_modules: set[str]
) -> bool:
# Rust unit tests live INSIDE source files (`#[cfg(test)] mod tests`), so
# path-based test detection never sees them (issue #1008). Test code is a
# function carrying a `#[test]` family attribute (#[test], #[tokio::test],
# #[bench]) or any symbol inside a `tests`/`test` MODULE (the #[cfg(test)]
# convention), including the plain helpers such modules define. The
# module check walks the qn's PREFIXES against real Module qns: a type
# or method named `tests`, or a dotted project name, shares the string
# shape but has no Module node and must stay reportable.
if not path.endswith(cs.EXT_RS):
return False
if rust_test_modules:
prefix = ""
for segment in qn.split(cs.SEPARATOR_DOT)[:-1]:
prefix = f"{prefix}{cs.SEPARATOR_DOT}{segment}" if prefix else segment
if prefix in rust_test_modules:
return True
return _has_rust_test_attribute(props)
def _rust_test_modules_from_nodes(
nodes: dict[_NodeId, PropertyDict],
) -> set[str]:
# Test modules by three signals, all gated to Rust files (inline `mod
# tests` blocks carry synthesised inline paths): a test-module NAME
# spelling, an OWN `#[cfg(test)]` decorator (bodied inline mods), or a
# DECLARATION-recorded gate (`#[cfg(test)] mod testutil;` stores its
# target-qn candidates on the declaring module, issue #1010). Declared
# candidates count only when they name a real Rust module here: a
# spelling the qn scheme does not produce (a #[path] override) must
# stay inert instead of mismarking whatever shares the string.
modules: set[str] = set()
rust_modules: set[str] = set()
declared: list[str] = []
ungated: set[str] = set()
for (label, uid), props in nodes.items():
if label != _MODULE:
continue
declared_here = props.get(cs.KEY_RUST_CFG_TEST_MODS)
if isinstance(declared_here, list):
declared.extend(str(target) for target in declared_here)
ungated_here = props.get(cs.KEY_RUST_UNGATED_MODS)
if isinstance(ungated_here, list):
ungated.update(str(target) for target in ungated_here)
path = str(props.get(cs.KEY_PATH, ""))
if not (
path.endswith(cs.EXT_RS) or path.startswith(cs.INLINE_MODULE_PATH_PREFIX)
):
continue
qn = str(uid)
rust_modules.add(qn)
if qn.rsplit(cs.SEPARATOR_DOT, 1)[
-1
] in cs.RUST_TEST_MODULE_SEGMENTS or _has_rust_cfg_test_gate(props):
modules.add(qn)
# An ungated declaration from ANY target (src/main.rs compiling the
# module for production) outweighs a gated sibling declaration.
modules.update(
target
for target in declared
if target in rust_modules and target not in ungated
)
return modules
def _has_rust_cfg_test_gate(props: PropertyDict) -> bool:
decorators = props.get(cs.KEY_DECORATORS)
if not isinstance(decorators, list):
return False
return any(
"".join(str(decorator).split()) == cs.RS_CFG_TEST_ATTRIBUTE
for decorator in decorators
)
def _rust_test_fn_spans(
nodes: dict[_NodeId, PropertyDict],
) -> dict[str, list[tuple[int, int]]]:
# Spans of `#[test]` family functions per Rust file: a nested fn
# registers FLAT under the module qn and a closure as
# anonymous_<line>_<col>, so with tests excluded, symbols lexically
# inside a suppressed test fn are recognised by span containment.
spans: dict[str, list[tuple[int, int]]] = {}
for (label, uid), props in nodes.items():
if label not in (_FUNCTION, _METHOD):
continue
path = str(props.get(cs.KEY_PATH, ""))
if not path.endswith(cs.EXT_RS) or not _has_rust_test_attribute(props):
continue
start = props.get(cs.KEY_START_LINE)
end = props.get(cs.KEY_END_LINE)
if isinstance(start, int) and isinstance(end, int) and start > 0:
spans.setdefault(path, []).append((start, end))
return spans
def _is_test_symbol(
props: PropertyDict,
qn: str,
path: str,
test_patterns: tuple[str, ...],
rust_test_modules: set[str],
rust_test_spans: dict[str, list[tuple[int, int]]],
) -> bool:
# One definition for BOTH polarities: a symbol excluded as test code
# when tests are off must be the same symbol rooted when they are on,
# or the two modes silently diverge.
return (
matches_test_path(path, test_patterns)
or _is_rust_test_symbol(props, qn, path, rust_test_modules)
or _within_rust_test_span(props, rust_test_spans)
)
def _within_rust_test_span(
props: PropertyDict, spans: dict[str, list[tuple[int, int]]]
) -> bool:
path_spans = spans.get(str(props.get(cs.KEY_PATH, "")))
if not path_spans:
return False
start = props.get(cs.KEY_START_LINE)
end = props.get(cs.KEY_END_LINE)
if not isinstance(start, int) or not isinstance(end, int) or start <= 0:
return False
# Strict on both ends: spans carry no columns, so a production fn
# packed onto the test fn's first or last physical line must not be
# swallowed; every rustfmt-shaped nested symbol sits strictly inside.
return any(s < start and end < e for s, e in path_spans)
def _is_c_cpp_entry_root(
name: str, is_method: bool, path: str, qn: str, project_prefix: str
) -> bool:
# A C/C++ program entry (`main`, Windows' `wWinMain`/`WinMain`/`wmain`, a
# DLL's `DllMain`) is invoked by the OS runtime, never by a call the graph
# sees, so it roots its whole call tree (an unrooted wWinMain reported all
# 34 windows/runner symbols of a Flutter desktop shim dead). Only a free
# function at FILE scope in a translation-unit source counts: a method, a
# namespace-scoped `main`, or a header-defined `WinMain` is ordinary code
# the OS cannot invoke. (Linkage is not captured in the graph, so a
# file-scope `static DllMain` in a source file still roots.)
if is_method or name not in cs.C_CPP_ENTRY_FUNCTION_NAMES:
return False
if not path.endswith(cs.C_CPP_SOURCE_EXTENSIONS):
return False
# File scope, exactly: a file-scope definition's qn is the project prefix
# plus the path's dotted form (extension dropped) plus the name
# (`proj.runner.main.wWinMain` for runner/main.cpp). A namespace inserts
# its own segment, even one named like the file stem (`namespace main`
# in main.cpp), so nothing short of the exact qn earns the root.
dotted_module = path.rsplit(cs.SEPARATOR_DOT, 1)[0].replace(
cs.SEPARATOR_SLASH, cs.SEPARATOR_DOT
)
return qn == f"{project_prefix}{dotted_module}{cs.SEPARATOR_DOT}{name}"
def _is_cpp_operator_root(name: str, path: str) -> bool:
# A C++ operator overload / user-defined literal (`operator==`, `operator[]`,
# `operator""_json`) is invoked by operator/literal SYNTAX, not a named call
# the graph sees, so it is a reachability root (like Python dunders / Rust
# trait methods). `operator` heads every such definition (member or free),
# so the name prefix on a C++ file identifies them.
return name.startswith(cs.CPP_OPERATOR_PREFIX) and path.endswith(cs.CPP_EXTENSIONS)
def _is_js_well_known_symbol_root(name: str, is_method: bool, path: str) -> bool:
# A JS/TS class member keyed by a well-known symbol (`[Symbol.iterator]`,
# `get [Symbol.toStringTag]`) is invoked implicitly by the language
# runtime (iteration protocol, Object.prototype.toString, using/dispose),
# never by a name the graph can see, so it is a reachability root (like
# Python dunders / Rust trait methods). The registered leaf keeps the
# computed-name brackets, so the `[Symbol.` prefix on a JS/TS file
# identifies exactly these members; a user symbol key registers without
# the `Symbol.` path and stays ordinary code.
if not is_method or not path.endswith(_JS_TS_EXTS):
return False
# Formatting must not decide (`[ Symbol.iterator ]`, `[\tSymbol.iterator\t]`,
# `[Symbol["iterator"]]` spell the same protocol member): compare free of
# ALL whitespace, accepting the dotted and the bracket-notation access off
# the Symbol global. The member itself must sit on an allowlist: the
# well-known set, plus the registry keys runtimes invoke themselves
# (Node calls `Symbol.for('nodejs.util.inspect.custom')`). An
# application-defined `Symbol.for('app.tag')` member is reached only by
# code the graph can see, so rooting it would hide dead protocol members.
# A string key (`['Symbol.fake']`) or user symbol variable (`[mySym]`)
# keeps its own spelling and never matches.
member = _js_symbol_member(name)
if member is None:
return False
if member in cs.JS_WELL_KNOWN_SYMBOLS:
return True
return _js_registry_symbol_key(member) in cs.JS_RUNTIME_REGISTRY_SYMBOL_KEYS
def _js_symbol_member(name: str) -> str | None:
compact = "".join(name.split())
if not compact.endswith(cs.JS_COMPUTED_NAME_SUFFIX):
return None
if compact.startswith(cs.JS_WELL_KNOWN_SYMBOL_NAME_PREFIX):
return compact[len(cs.JS_WELL_KNOWN_SYMBOL_NAME_PREFIX) : -1]
if compact.startswith(cs.JS_WELL_KNOWN_SYMBOL_BRACKET_PREFIX):
return _js_unquote(compact[len(cs.JS_WELL_KNOWN_SYMBOL_BRACKET_PREFIX) : -2])
return None
def _js_registry_symbol_key(member: str | None) -> str | None:
if (
member is None
or not member.startswith(cs.JS_SYMBOL_FOR_PREFIX)
or not member.endswith(cs.CHAR_PAREN_CLOSE)
):
return None
return _js_unquote(member[len(cs.JS_SYMBOL_FOR_PREFIX) : -1])
def _js_unquote(text: str) -> str | None:
if len(text) > 1 and text[0] == text[-1] and text[0] in cs.JS_STRING_QUOTES:
return text[1:-1]
return None
def _is_java_serialization_root(name: str, is_method: bool, path: str) -> bool:
# A Java serialization hook (`readObject`/`writeObject`/`writeReplace`/
# `readResolve`/`readObjectNoData`) is invoked reflectively by the java.io
# runtime, never by a named call the graph sees, so it is a reachability root
# (like Python dunders / Rust trait methods). Gated to methods on a .java
# file; `name` is the bare method name (signature stripped by caller).
return (
is_method
and path.endswith(cs.EXT_JAVA)
and name in cs.JAVA_SERIALIZATION_METHOD_NAMES
)
def _is_csharp_attribute_root(props: PropertyDict, path: str) -> bool:
# A C# method carrying a framework/runtime attribute ([Fact], [HttpGet],
# [OnDeserialized]) is invoked reflectively, never by a call the graph sees,
# so it is a reachability root. Gated to .cs; the decorator set matches via
# the normalized (lowercased, arg-stripped) form.
return path.endswith(cs.EXT_CS) and _has_root_decorator(
props, cs.CSHARP_ROOT_ATTRIBUTES
)
def _is_csharp_dispose_root(name: str, is_method: bool, path: str) -> bool:
# `Dispose`/`DisposeAsync` are invoked by a `using` block's teardown, not
# a named call; a reachability root on a .cs method (like the Java hooks).
return (
is_method
and path.endswith(cs.EXT_CS)
and name in cs.CSHARP_DISPOSE_METHOD_NAMES
)
def _is_csharp_operator_or_finalizer_root(name: str, path: str) -> bool:
# An operator overload is invoked by operator SYNTAX (`a + b`) and a
# finalizer (`~Foo`) by the GC, never a named call the graph sees, so both
# are reachability roots on a .cs file (cf. the C++ operator root). The
# synthesized leaf carries the `operator_`/`~` prefix.
return path.endswith(cs.EXT_CS) and (
name.startswith(cs.TS_CSHARP_OPERATOR_NAME_PREFIX)
or name.startswith(cs.TS_CSHARP_DESTRUCTOR_NAME_PREFIX)
)
_WELL_KNOWN_SYMBOL_KEY_RE = re.compile(r"\[Symbol\.(?P<name>[A-Za-z_$][\w$]*)\]$")
def is_well_known_symbol_member(name: str) -> bool:
m = _WELL_KNOWN_SYMBOL_KEY_RE.search(name)
return m is not None and m.group("name") in cs.JS_WELL_KNOWN_SYMBOLS
def _has_root_decorator(props: PropertyDict, root_decorators: frozenset[str]) -> bool:
decorators = props.get(cs.KEY_DECORATORS)
if not isinstance(decorators, list):
return False
return any(_norm_decorator(str(d)) in root_decorators for d in decorators)
def _is_nest_component_class(
class_qn: str,
class_decorators_norm: dict[str, frozenset[str]],
nest_factory_classes: set[str],
) -> bool:
if class_qn in nest_factory_classes:
return True
decorators = class_decorators_norm.get(class_qn)
return bool(decorators and decorators & cs.NEST_ROOT_CLASS_DECORATORS)
def _is_nest_root(
qn: str,
member: str,
is_method: bool,
path: str,
method_to_class: dict[str, str],
class_decorators_norm: dict[str, frozenset[str]],
nest_factory_classes: set[str],
) -> bool:
# NestJS runs code the static graph sees no call to. A class decorated
# @Injectable/@Controller/@Module/... is instantiated by the DI container
# (root its constructor) and driven by the framework through lifecycle and
# single-method interface contracts (root those methods by name). A class
# implementing an EXTERNAL `...OptionsFactory` interface has its factory
# method invoked by Nest, so root all its methods (mirrors overrides_external).
# Gated to JS/TS; a same-named ordinary method on a plain class is untouched.
if not path.endswith(_JS_TS_EXTS):
return False
if not is_method:
# A CLASS-node candidate (only present with --classes): the component
# class is instantiated by the container, so it roots itself -- a rooted
# constructor cannot revive it because DEFINES_METHOD is not a
# reachability edge. (A non-class, non-method candidate -- a bare
# function -- is not in either map, so this returns False.)
return _is_nest_component_class(qn, class_decorators_norm, nest_factory_classes)
cls = method_to_class.get(qn)
if cls is None:
return False
if cls in nest_factory_classes:
return True
decorators = class_decorators_norm.get(cls)
if decorators and decorators & cs.NEST_ROOT_CLASS_DECORATORS:
return (
member == cs.KEYWORD_CONSTRUCTOR or member in cs.NEST_FRAMEWORK_METHOD_NAMES
)
return False
def _is_react_base_qn(base_qn: str) -> bool:
# A React component base: the simple name is `Component`/`PureComponent` AND
# it lives in a react-namespaced module (`react.Component`, `React.Component`,
# `react.PureComponent`). The namespace check keeps an unrelated base that
# merely SHARES the `Component` simple name (Ember/Glimmer's
# `@glimmer/component.Component`, a bespoke `ui.Component`) from being taken
# for React.
namespace, sep, leaf = base_qn.rpartition(cs.SEPARATOR_DOT)
return (
bool(sep)
and leaf in cs.REACT_COMPONENT_BASE_NAMES
and namespace.lower() == cs.REACT_NAMESPACE_TOKEN
)
def _is_react_root(
qn: str,
member: str,
is_method: bool,
path: str,
method_to_class: dict[str, str],
react_component_classes: set[str],
) -> bool:
# A React class-component lifecycle method (render/componentDidMount/... and
# the constructor React calls on instantiation) is invoked by React, never by
# a first-party call the graph sees, so it is a reachability root on a class
# that INHERITS a React component base. The methods/callbacks it reaches via
# `this.` then expand from it. Gated to JS/TS methods; a same-named method on
# a plain (non-React) class is untouched.
if not is_method or not path.endswith(_JS_TS_EXTS):
return False
if member not in cs.REACT_LIFECYCLE_METHOD_NAMES:
return False
cls = method_to_class.get(qn)
return cls is not None and cls in react_component_classes
def _walk(
frontier: set[str],
adjacency: dict[str, set[str]],
live: set[str],
added: set[str] | None = None,
) -> None:
stack = list(frontier)
while stack:
current = stack.pop()
for nxt in adjacency.get(current, ()):
if nxt not in live:
live.add(nxt)
if added is not None:
added.add(nxt)
stack.append(nxt)
def _is_root(
qn: str,
props: PropertyDict,
config: DeadCodeConfig,
method_qns: set[str],
protocol_stubs: set[str],
method_to_class: dict[str, str],
class_decorators_norm: dict[str, frozenset[str]],
nest_factory_classes: set[str],
react_component_classes: set[str],
rust_test_modules: set[str],
rust_test_spans: dict[str, list[tuple[int, int]]],
project_prefix: str,
) -> bool:
"""Whether any name-, path- or decorator-scoped rule makes `qn` a root.
The rules are a tuple of thunks evaluated lazily in order by `any`: the
same first-match semantics as the `elif` chain this replaced, without
the branch per rule that put the chain over Sonar's complexity limit.
"""
# The duplicate-qn marker (`init@51`, a SECOND Go init() in one file)
# is a registration artifact, never part of the written name; strip it
# so every name-scoped root rule sees the real leaf (kubernetes
# pkg.apis.abac register.init@51 reported dead).
leaf = qn.rsplit(cs.SEPARATOR_DOT, 1)[-1].split(cs.DUP_QN_MARKER, 1)[0]
path = str(props.get(cs.KEY_PATH, ""))
is_method = qn in method_qns
bare_leaf = leaf.split(cs.CHAR_PAREN_OPEN, 1)[0]
rules: tuple[Callable[[], bool], ...] = (
lambda: _has_root_decorator(props, config.root_decorators),
lambda: props.get(cs.KEY_IS_EXPORTED) is True,
# A method overriding an EXTERNAL stdlib base's method (click's
# textwrap.TextWrapper subclass) is invoked by the base's machinery,
# never by a first-party call, so it is a root.
lambda: props.get(cs.KEY_OVERRIDES_EXTERNAL) is True,
lambda: qn in protocol_stubs,
lambda: is_method and _is_dunder(leaf) and path.endswith(cs.EXT_PY),
# Python Enum protocol hooks (_generate_next_value_, _missing_) are
# invoked by the enum machinery by NAME, like dunders: roots, not
# dead code (django's TextChoices._generate_next_value_).
lambda: (
is_method
and leaf in cs.PY_ENUM_HOOK_METHOD_NAMES
and path.endswith(cs.EXT_PY)
),
lambda: (
not is_method
and leaf in cs.GO_ROOT_FUNCTION_NAMES
and path.endswith(cs.EXT_GO)
),
lambda: _is_rust_runtime_root(leaf, is_method, path),
# NOT leaf-based: the computed name contains a dot, so the qn's
# last dotted segment is `toStringTag]`; match on the bracketed
# member name as registered.
lambda: _is_js_well_known_symbol_root(
str(props.get(cs.KEY_NAME) or ""), is_method, path
),
lambda: (
_is_cpp_operator_root(leaf, path)
or _is_c_cpp_entry_root(leaf, is_method, path, qn, project_prefix)
),
lambda: _is_java_serialization_root(bare_leaf, is_method, path),
lambda: _is_csharp_attribute_root(props, path),
lambda: _is_csharp_dispose_root(bare_leaf, is_method, path),
lambda: _is_csharp_operator_or_finalizer_root(leaf, path),
lambda: _is_nest_root(
qn,
bare_leaf,
is_method,
path,
method_to_class,
class_decorators_norm,
nest_factory_classes,
),
lambda: _is_react_root(
qn, bare_leaf, is_method, path, method_to_class, react_component_classes
),
lambda: (
is_well_known_symbol_member(qn)
and str(props.get(cs.KEY_PATH, "")).endswith(cs.JS_TS_ALL_EXTENSIONS)
),
lambda: any(qn.endswith(entry) for entry in config.entry_points),
lambda: (
config.include_tests
and _is_test_symbol(
props,
qn,
path,
config.test_patterns,
rust_test_modules,
rust_test_spans,
)
),
)
return any(rule() for rule in rules)
def dead_code_from_graph(
nodes: dict[_NodeId, PropertyDict],
rels: list[_RelTuple],
project_prefix: str,
config: DeadCodeConfig,
) -> set[str]:
labels = {_FUNCTION, _METHOD}
traversal = {_CALLS, _REFERENCES}
module_rels = {_CALLS, _REFERENCES}
if config.include_classes:
labels.add(_CLASS)
traversal |= {_INSTANTIATES, _INHERITS}
module_rels.add(_INSTANTIATES)
candidates: set[str] = set()
props_by_qn: dict[str, PropertyDict] = {}
method_qns: set[str] = set()
module_path: dict[str, str] = {}
rust_test_modules = _rust_test_modules_from_nodes(nodes)
# Both polarities need the spans: excluded test fns take their nested
# symbols with them, and INCLUDED ones must root those same symbols (a
# fn passed as a value, `filter(is_even)`, has no CALLS edge to revive
# it).
rust_test_spans = _rust_test_fn_spans(nodes)
# Normalized decorators per CLASS qn (collected for every class, not just
# class candidates), so a method root rule can consult its class's
# @Injectable/@Controller/@Module marker (NestJS DI roots, issue #973).
class_decorators_norm: dict[str, frozenset[str]] = {}
for (label, uid), props in nodes.items():
if label == _MODULE:
module_path[str(uid)] = str(props.get(cs.KEY_PATH, ""))
if label == _CLASS:
decorators = props.get(cs.KEY_DECORATORS)
if isinstance(decorators, list):
class_decorators_norm[str(uid)] = frozenset(
_norm_decorator(str(d)) for d in decorators
)
if label in labels and str(uid).startswith(project_prefix):
# With tests excluded, a test symbol's only callers are excluded
# as roots, so reporting it is noise (test helpers and mocks are
# infrastructure, not dead production code). Rust test code lives
# INSIDE source files, so it is matched by attribute/module, not
# path (issue #1008).
if not config.include_tests and _is_test_symbol(
props,
str(uid),
str(props.get(cs.KEY_PATH) or ""),
config.test_patterns,
rust_test_modules,
rust_test_spans,
):
continue
candidates.add(str(uid))
props_by_qn[str(uid)] = props
if label == _METHOD:
method_qns.add(str(uid))
roots: set[str] = set()
# A method of a typing.Protocol subclass is an interface stub whose callers
# resolve to the implementations; DEFINES edges from functions/methods feed
# the live-owner registration round below.
defines_pairs: list[tuple[str, str]] = []
protocol_classes: set[str] = set()
class_methods: list[tuple[str, str]] = []
nested_class_pairs: list[tuple[str, str]] = []
# A class implementing an EXTERNAL NestJS `...OptionsFactory` interface (one
# not defined in this project) has its factory method invoked by Nest, so its
# methods are roots (issue #973). Restricted to that naming convention so an
# unrelated third-party interface implementer is not force-rooted.
nest_factory_classes: set[str] = set()
# A class that `extends` a React component base (directly or through a
# first-party intermediate base) is a class component whose lifecycle methods
# React drives at runtime (issue #978). Seeds are direct extenders; the
# transitive closure over INHERITS is computed after the scan.
react_component_classes: set[str] = set()
inherits_subclasses: dict[str, set[str]] = defaultdict(set)
for from_label, from_val, rel_type, to_label, to_val in rels:
if rel_type == _DEFINES and from_label in (_FUNCTION, _METHOD):
defines_pairs.append((str(from_val), str(to_val)))
if to_label == _CLASS:
nested_class_pairs.append((str(from_val), str(to_val)))
elif rel_type == _INHERITS:
inherits_subclasses[str(to_val)].add(str(from_val))
if str(to_val) in cs.PROTOCOL_BASE_QNS:
protocol_classes.add(str(from_val))
elif _is_react_base_qn(str(to_val)):
react_component_classes.add(str(from_val))
elif rel_type == _DEFINES_METHOD:
class_methods.append((str(from_val), str(to_val)))
elif (
rel_type == _IMPLEMENTS
and not str(to_val).startswith(project_prefix)
and str(to_val)
.rsplit(cs.SEPARATOR_DOT, 1)[-1]
.endswith(cs.NEST_OPTIONS_FACTORY_SUFFIX)
):
nest_factory_classes.add(str(from_val))
if from_label != _MODULE or rel_type not in module_rels:
continue
target_qn = str(to_val)
if target_qn not in candidates:
continue
path = module_path.get(str(from_val), "")
is_test = matches_test_path(path, config.test_patterns)
if config.include_tests or not is_test:
roots.add(target_qn)
protocol_stubs = {m for c, m in class_methods if c in protocol_classes}
method_to_class = {m: c for c, m in class_methods}
# Expand React components down the inheritance tree: a class extending a
# first-party base that (transitively) extends react.Component is itself a
# React component (a shared `BaseComponent extends React.Component` is common).
_walk(set(react_component_classes), inherits_subclasses, react_component_classes)
for qn in candidates:
if qn in roots:
continue
props = props_by_qn[qn]
# Every rule below makes `qn` a root; they are alternatives, not a
# priority order, so one membership test replaces a chain of
# identical branches (Sonar S1871, #1669). Each rule keeps its
# comment where it applies.
if _is_root(
qn,
props,
config,
method_qns,
protocol_stubs,
method_to_class,
class_decorators_norm,
nest_factory_classes,
react_component_classes,
rust_test_modules,
rust_test_spans,
project_prefix,
):
roots.add(qn)
adjacency: dict[str, set[str]] = defaultdict(set)
# OVERRIDES is recorded overrider -> overridden; keep the REVERSE mapping
# (overridden -> overriders) to expand virtual-dispatch targets below.
override_rev: dict[str, set[str]] = defaultdict(set)
for from_label, from_val, rel_type, _to_label, to_val in rels:
if rel_type in traversal:
adjacency[str(from_val)].add(str(to_val))
elif rel_type == _OVERRIDES:
override_rev[str(to_val)].add(str(from_val))
live = set(roots)
_walk(roots, adjacency, live)
# Second expansion: a decorated function DEFINED by a LIVE owner is
# framework-registered when the owner runs, so it and its callees are
# live; the closure of a DEAD owner never registers and stays in the
# reported cluster. ponytail: one round, so a registration chain nested
# two closures deep is missed; iterate to fixed point if real code ever
# registers closures from inside registered closures.
closure_roots = {
c
for o, c in defines_pairs
if o in live
and c not in live
and c in props_by_qn
and props_by_qn[c].get(cs.KEY_DECORATORS)
}
live |= closure_roots
_walk(closure_roots, adjacency, live)
# Factory-class and override expansions, iterated together to a fixed
# point because they feed each other (a factory revived only via an
# override's callee still needs its class rooted, and vice versa).
#
# Factory-class rule: a class defined inside a LIVE function
# (django's create_reverse_many_to_one_manager) escapes via its return
# value or arguments, so no call edge lands on its methods. Treat them as
# dispatch surface and revive their callee closure; a DEAD factory's
# class stays dead.
#
# Override rule: a call to a base or interface method dispatches at
# runtime to any override, so every transitive override of a LIVE method
# is a reachable dispatch target, as is its callee closure. `override_rev`
# walks all multi-level overriders (Base<-Sub<-SubSub); an override of a
# DEAD base stays dead.
#
# Each round scans only nodes revived since the last (the pair maps and
# override_rev are static, so a rescanned node yields nothing new),
# keeping the loop O(live) total; a round that adds nothing ends it.
classes_by_owner: dict[str, set[str]] = defaultdict(set)
for owner, cls in nested_class_pairs:
classes_by_owner[owner].add(cls)
methods_by_class: dict[str, set[str]] = defaultdict(set)
for cls, m in class_methods:
methods_by_class[cls].add(m)
frontier = set(live)
while frontier:
added: set[str] = set()
factory_method_roots: set[str] = set()
for owner in frontier:
for cls in classes_by_owner.get(owner, ()):
if cls not in live:
live.add(cls)
added.add(cls)
factory_method_roots |= methods_by_class[cls] - live
live |= factory_method_roots
added |= factory_method_roots
_walk(factory_method_roots, adjacency, live, added=added)
override_roots: set[str] = set()
stack = list(frontier | added)
while stack:
for overrider in override_rev.get(stack.pop(), ()):
if overrider not in live and overrider not in override_roots:
override_roots.add(overrider)
stack.append(overrider)
live |= override_roots
added |= override_roots
_walk(override_roots, adjacency, live, added=added)
frontier = added
dead = candidates - live
# Suppress generated files (openapi-ts client/core, routeTree.gen.ts) from
# the REPORT only, after reachability: they stay full participants as roots
# and callers, so a real function invoked only from generated glue is not
# newly flagged; excluding earlier would drop those live edges.
if config.exclude_patterns:
dead = {
qn
for qn in dead
if not any(
fnmatch(str(props_by_qn[qn].get(cs.KEY_PATH) or ""), pattern)
for pattern in config.exclude_patterns
)
}
return dead
def _as_str_list(value: ResultValue | None) -> list[str]:
if not isinstance(value, list):
return []
return [str(item) for item in value]
def _node_props(row: ResultRow) -> PropertyDict:
# Coalesce NULL column values at the fetch boundary so the engine never
# sees None where a str/list/bool is expected. Only properties the engine
# reads are kept; the report is built from the raw rows.
return {
cs.KEY_PATH: str(row.get(cs.KEY_PATH) or ""),
# The registered member NAME survives here because a computed
# well-known-symbol name (`[Symbol.toStringTag]`) contains a dot and
# cannot be recovered from the qn's last dotted segment.
cs.KEY_NAME: str(row.get(cs.KEY_NAME) or ""),
cs.KEY_DECORATORS: _as_str_list(row.get(cs.KEY_DECORATORS)),
cs.KEY_IS_EXPORTED: row.get(cs.KEY_IS_EXPORTED) is True,
cs.KEY_OVERRIDES_EXTERNAL: row.get(cs.KEY_OVERRIDES_EXTERNAL) is True,
# Spans feed the Rust nested-test-symbol exclusion (issue #1008);
# non-int values coalesce to 0 so the containment checks skip them.
cs.KEY_START_LINE: _as_line(row.get(cs.KEY_START_LINE)),
cs.KEY_END_LINE: _as_line(row.get(cs.KEY_END_LINE)),
cs.KEY_RUST_CFG_TEST_MODS: _as_str_list(row.get(cs.KEY_RUST_CFG_TEST_MODS)),
cs.KEY_RUST_UNGATED_MODS: _as_str_list(row.get(cs.KEY_RUST_UNGATED_MODS)),
}
def _as_line(value: object) -> int:
return value if isinstance(value, int) else 0
def _row_qn(row: ResultRow) -> str:
return str(row.get(cs.KEY_QUALIFIED_NAME) or "")
def count_structural_tier_symbols(node_rows: list[ResultRow]) -> int:
"""Count symbols the reachability walk could never have reported.
The ast-grep tier emits no CALLS edges and marks its symbols exported, so
they are unconditional roots. Reporting this count keeps a "no dead code"
result honest about the languages that were never analyzed.
"""
from .parsers.ast_grep_tier import structural_tier_extensions
extensions = structural_tier_extensions()
if not extensions:
return 0
symbol_labels = {_FUNCTION, _METHOD, _CLASS}
return sum(
1
for row in node_rows
if str(row.get(cs.KEY_LABEL) or "") in symbol_labels
and str(row.get(cs.KEY_PATH) or "").endswith(tuple(extensions))
)
def collect_dead_code(
ingestor: GraphQueryClient, project_name: str, config: DeadCodeConfig
) -> list[ResultRow]:
return collect_dead_code_with_coverage(ingestor, project_name, config)[0]
def collect_dead_code_with_coverage(
ingestor: GraphQueryClient, project_name: str, config: DeadCodeConfig
) -> tuple[list[ResultRow], int]:
"""Dead-code rows plus the count of symbols no analysis could cover.
The count rides along here because it needs the unfiltered node rows, which
only this fetch has; recomputing it in the CLI would mean a second query.
"""
prefix = project_name + cs.SEPARATOR_DOT
params: dict[str, PropertyValue] = {cs.KEY_PROJECT_PREFIX: prefix}
node_rows = ingestor.fetch_all(cq.CYPHER_DEAD_CODE_NODES, params)
nodes: dict[_NodeId, PropertyDict] = {
(str(row.get(cs.KEY_LABEL) or ""), _row_qn(row)): _node_props(row)
for row in node_rows
}
rels: list[_RelTuple] = [
(
str(row.get(cs.KEY_FROM_LABEL) or ""),
str(row.get(cs.KEY_FROM_QN) or ""),
str(row.get(cs.KEY_REL_TYPE) or ""),
str(row.get(cs.KEY_TO_LABEL) or ""),
str(row.get(cs.KEY_TO_QN) or ""),
)
for row in ingestor.fetch_all(cq.CYPHER_DEAD_CODE_RELS, params)
if _passes_floor(row, config.min_resolution)
]
dead = dead_code_from_graph(nodes, rels, prefix, config)
rows = [row for row in node_rows if _row_qn(row) in dead]
rows.sort(key=_row_qn)
return rows, count_structural_tier_symbols(node_rows)