Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions codegraph/analysis/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,64 @@ def _kind_str(value: object) -> str:
)


EXCLUDED_PATH_FRAGMENTS: tuple[str, ...] = (
"tests/fixtures/",
"tests\\fixtures\\",
"/static/",
"\\static\\",
)


def is_excluded_path(file_path: str) -> bool:
"""True iff the file path is under a directory excluded from analysis.

Test fixtures and static frontend assets don't have traceable call graphs
and should not be analysed for dead-code or untested-symbol detection.
"""
if not file_path:
return False
return any(fragment in file_path for fragment in EXCLUDED_PATH_FRAGMENTS)


def is_protocol_class(graph: nx.MultiDiGraph, class_id: str) -> bool:
"""True iff the class inherits from ``typing.Protocol``.

Walks INHERITS out-edges and matches any parent whose target name ends in
``Protocol``. This covers ``Protocol``, ``typing.Protocol``, and the
parser's ``unresolved::Protocol`` / ``unresolved::typing.Protocol`` forms.
"""
for _src, dst, key, data in graph.out_edges(class_id, keys=True, data=True):
if key != EdgeKind.INHERITS.value:
continue
target_name = ""
meta = data.get("metadata") or {}
if isinstance(meta, dict):
target_name = str(meta.get("target_name") or "")
if not target_name:
attrs = graph.nodes.get(dst) or {}
target_name = str(attrs.get("name") or attrs.get("qualname") or dst)
# Strip an unresolved:: prefix if the dst ID was used as fallback.
if target_name.startswith("unresolved::"):
target_name = target_name.split("::", 1)[1]
# Match bare "Protocol" or any dotted form ending with ".Protocol".
if target_name == "Protocol" or target_name.endswith(".Protocol"):
return True
return False


def in_protocol_class(graph: nx.MultiDiGraph, method_id: str) -> bool:
"""True iff this method's owning class is a typing.Protocol."""
for _src, dst, key in graph.out_edges(method_id, keys=True):
if key != EdgeKind.DEFINED_IN.value:
continue
attrs = graph.nodes.get(dst) or {}
if _kind_str(attrs.get("kind")) != "CLASS":
continue
if is_protocol_class(graph, dst):
return True
return False


def in_test_module(graph: nx.MultiDiGraph, node_id: str) -> bool:
"""True iff the node is in a file whose MODULE node is marked is_test."""
attrs = graph.nodes.get(node_id) or {}
Expand Down
25 changes: 11 additions & 14 deletions codegraph/analysis/dead_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
from codegraph.analysis._common import (
REFERENCE_EDGE_KINDS,
_kind_str,
in_protocol_class,
in_test_module,
is_excluded_path,
is_protocol_class,
)
from codegraph.graph.schema import EdgeKind, NodeKind

Expand All @@ -21,13 +24,6 @@
"@property", "@cached_property", "functools.cached_property",
)

_EXCLUDED_PATH_FRAGMENTS: tuple[str, ...] = (
"tests/fixtures/",
"tests\\fixtures\\",
"/static/",
"\\static\\",
)


def _has_property_decorator(metadata: dict[str, object]) -> bool:
decorators = metadata.get("decorators") or []
Expand All @@ -41,12 +37,6 @@ def _has_property_decorator(metadata: dict[str, object]) -> bool:
return False


def _is_excluded_path(file_path: str) -> bool:
if not file_path:
return False
return any(fragment in file_path for fragment in _EXCLUDED_PATH_FRAGMENTS)


def _class_has_inherits(graph: nx.MultiDiGraph, class_id: str) -> bool:
return any(
key == EdgeKind.INHERITS.value
Expand Down Expand Up @@ -133,7 +123,14 @@ def find_dead_code(
continue
# Generated/static frontend assets and test fixtures don't have
# traceable call graphs — exclude them from dead-code detection.
if _is_excluded_path(str(attrs.get("file") or "")):
if is_excluded_path(str(attrs.get("file") or "")):
continue
# Skip ``typing.Protocol`` classes and their methods. Protocols define
# structural types for static type checking; they have no runtime
# call-graph incoming edges by design.
if kind == NodeKind.CLASS.value and is_protocol_class(graph, nid):
continue
if kind == NodeKind.METHOD.value and in_protocol_class(graph, nid):
continue
# Polymorphic overrides on classes that inherit have no static
# incoming CALL edge (dispatch is via the base class).
Expand Down
16 changes: 15 additions & 1 deletion codegraph/analysis/untested.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

import networkx as nx

from codegraph.analysis._common import _kind_str, in_test_module
from codegraph.analysis._common import (
_kind_str,
in_protocol_class,
in_test_module,
is_excluded_path,
)
from codegraph.graph.schema import EdgeKind, NodeKind

_CANDIDATE_KINDS: frozenset[str] = frozenset(
Expand Down Expand Up @@ -40,6 +45,15 @@ def find_untested(graph: nx.MultiDiGraph) -> list[UntestedNode]:
continue
if in_test_module(graph, nid):
continue
# Skip test fixtures and static frontend assets — same exclusion as
# the dead-code analyzer.
if is_excluded_path(str(attrs.get("file") or "")):
continue
# Skip methods defined inside a ``typing.Protocol`` class: Protocol
# methods are structural type definitions, not runtime code, so
# "untested" is meaningless for them.
if kind == NodeKind.METHOD.value and in_protocol_class(graph, nid):
continue
incoming = 0
from_test = 0
for src, _dst, key in graph.in_edges(nid, keys=True):
Expand Down
Loading
Loading