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
18 changes: 12 additions & 6 deletions src/codegraphcontext/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2830,15 +2830,18 @@ def _render_complexity_table(results, title):

@analyze_app.command("dead-code")
def analyze_dead_code(
path: Optional[str] = typer.Argument(None, help="Path to analyze (not yet implemented)"),
path: Optional[str] = typer.Argument(None, help="Repository path to scope the analysis to"),
exclude_decorators: Optional[str] = typer.Option(None, "--exclude", "-e", help="Comma-separated decorators to exclude"),
context: Optional[str] = typer.Option(None, "--context", "-c", help="Specific context to use"),
):
"""
Find potentially unused functions and classes.

Find potentially unused functions.

Without a path the whole database is scanned, which on a shared graph
reports dead code from every indexed repository. Pass a path to scope it.

Example:
cgc analyze dead-code
cgc analyze dead-code .
cgc analyze dead-code --exclude route,task,api
"""
_load_credentials()
Expand All @@ -2849,8 +2852,11 @@ def analyze_dead_code(

try:
exclude_list = exclude_decorators.split(',') if exclude_decorators else []
results = code_finder.find_dead_code(exclude_list)

# `path` was accepted and then dropped, so running inside one repository
# still reported dead code from every repository in the database.
repo_path = Path(path).resolve().as_posix() if path else None
results = code_finder.find_dead_code(exclude_list, repo_path=repo_path)

unused_funcs = results.get('potentially_unused_functions', [])

if not unused_funcs:
Expand Down
27 changes: 20 additions & 7 deletions src/codegraphcontext/tools/code_finder.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@

_MAX_TRAVERSAL_DEPTH = 20

# Names that are entry points rather than dead code. Matched on the *whole*
# name, lowercased. This used to be a substring test (`name CONTAINS 'main'`,
# `toLower(name) CONTAINS 'entry'`), which silently excluded any function whose
# name merely contained one of them — `domain_check`, `remainder`,
# `maintain_index`, `entry_count` — so genuinely unused code was never
# reported and there was no way to tell.
_ENTRY_POINT_NAMES = (
"main",
"setup",
"run",
"application",
"entry",
"entrypoint",
)
_ENTRY_POINT_NAMES_CYPHER = "[" + ", ".join(f"'{n}'" for n in _ENTRY_POINT_NAMES) + "]"


def _sanitize_depth(depth, default: int = 3) -> int:
"""Coerce and clamp a traversal depth before interpolating it into Cypher.
Expand Down Expand Up @@ -809,19 +825,16 @@ def find_dead_code(self, exclude_decorated_with: Optional[List[str]] = None, rep
query = f"""
MATCH (func:Function)
WHERE func.is_dependency = false {repo_filter} {func_ignore}
AND NOT func.name IN ['main', 'setup', 'run']
AND NOT toLower(func.name) IN {_ENTRY_POINT_NAMES_CYPHER}
AND func.name <> '<module>'
AND NOT (func.name STARTS WITH '__' AND func.name ENDS WITH '__')
AND NOT func.name STARTS WITH '_test'
AND NOT func.name STARTS WITH 'test_'
AND NOT func.name CONTAINS 'main'
AND NOT toLower(func.name) CONTAINS 'application'
AND NOT toLower(func.name) CONTAINS 'entry'
AND NOT toLower(func.name) CONTAINS 'entrypoint'
{decorator_filter}
WITH func
OPTIONAL MATCH (caller:Function)-[:CALLS|HEURISTIC_CALLS]->(func)
WHERE caller.is_dependency = false {caller_ignore}
OPTIONAL MATCH (caller)-[:CALLS|HEURISTIC_CALLS]->(func)
WHERE (caller.is_dependency IS NULL OR caller.is_dependency = false)
{caller_ignore}
WITH func, count(caller) as caller_count
WHERE caller_count = 0
OPTIONAL MATCH (file:File)-[:CONTAINS]->(func)
Expand Down
15 changes: 14 additions & 1 deletion src/codegraphcontext/tools/report_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,12 +220,25 @@ def _section_cross_module_calls(driver: Any, limit: int = 20, repo_path: Optiona

def _section_dead_code(driver: Any, limit: int = 20, repo_path: Optional[str] = None) -> str:
"""Functions with no incoming CALLS edges (potential dead code)."""
# Share the entry-point list with `CodeFinder.find_dead_code` so the report
# and the CLI/MCP tool cannot disagree. They previously did, in both
# directions: this query had no name exclusions at all (so every dunder and
# `test_*` showed up as dead), while `find_dead_code` restricted callers to
# `(caller:Function)` and so missed the File-sourced edges that this one
# correctly counts.
from .code_finder import _ENTRY_POINT_NAMES_CYPHER

rows = _run_cypher(
driver,
"""
f"""
MATCH (fn:Function)
WHERE (fn.is_dependency IS NULL OR fn.is_dependency = false)
AND ($repo_path IS NULL OR fn.path STARTS WITH $repo_path)
AND NOT toLower(fn.name) IN {_ENTRY_POINT_NAMES_CYPHER}
AND fn.name <> '<module>'
AND NOT (fn.name STARTS WITH '__' AND fn.name ENDS WITH '__')
AND NOT fn.name STARTS WITH '_test'
AND NOT fn.name STARTS WITH 'test_'
AND NOT ()-[:CALLS|HEURISTIC_CALLS]->(fn)
RETURN fn.name AS name, fn.path AS path
ORDER BY fn.path, fn.name
Expand Down
100 changes: 100 additions & 0 deletions tests/unit/tools/test_dead_code_analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Dead-code analysis defects (#600, #1559).

The two implementations — `CodeFinder.find_dead_code` and the report
generator's `_section_dead_code` — each got something the other got wrong,
so the same repository produced different answers depending on which
surface you asked.
"""

from __future__ import annotations

import inspect

from codegraphcontext.tools import report_generator
from codegraphcontext.tools.code_finder import _ENTRY_POINT_NAMES, CodeFinder


def _query_src() -> str:
return inspect.getsource(CodeFinder.find_dead_code)


# --------------------------------------------------------------- #600
def test_caller_pattern_is_not_restricted_to_function_nodes():
"""A JS module-level call is persisted as a File-sourced edge.

`writer.py` has an explicit `caller_label == "File"` branch producing
`(:File)-[:CALLS]->(:Function)`. Restricting the caller to
`(caller:Function)` ignored those edges, so any JS function called only at
module level was reported dead — the report generator's query already got
this right with an anonymous caller.
"""
src = _query_src()
assert "OPTIONAL MATCH (caller)-[:CALLS|HEURISTIC_CALLS]->(func)" in src
assert "(caller:Function)-[:CALLS" not in src, "still ignores File-sourced callers"


def test_caller_filter_tolerates_nodes_without_is_dependency():
"""File nodes reached as callers may not carry `is_dependency`; a bare
equality test would drop them again through the back door."""
src = _query_src()
assert "caller.is_dependency IS NULL OR caller.is_dependency = false" in src


# --------------------------------------------------------------- #1559 (1)
def test_entry_point_names_match_whole_names_not_substrings():
"""`name CONTAINS 'main'` also excluded `domain_check`, `remainder` and
`maintain_index`, hiding genuinely unused code with no way to tell."""
src = _query_src()
# The query is an f-string, so the source carries the placeholder rather
# than the rendered list.
assert "toLower(func.name) IN {_ENTRY_POINT_NAMES_CYPHER}" in src, "must match whole names"
for bad in (
"func.name CONTAINS 'main'",
"toLower(func.name) CONTAINS 'application'",
"toLower(func.name) CONTAINS 'entry'",
):
assert bad not in src, f"substring filter still present: {bad}"


def test_names_that_merely_contain_an_entry_point_word_are_not_excluded():
"""The exclusion list is exact, so these are still analyzed."""
lowered = {n.lower() for n in _ENTRY_POINT_NAMES}
for name in ("domain_check", "remainder", "maintain_index", "entry_count", "runner"):
assert name not in lowered, f"{name} must not be treated as an entry point"


# --------------------------------------------------------------- #1559 (4)
def test_report_generator_applies_the_same_exclusions():
"""The report had no name exclusions at all, so every dunder and `test_*`
appeared as dead code in CGC_REPORT.md while the CLI filtered them out."""
src = inspect.getsource(report_generator._section_dead_code)
assert "_ENTRY_POINT_NAMES_CYPHER" in src, "must share the CLI's exclusion list"
for guard in ("STARTS WITH 'test_'", "STARTS WITH '__'", "<module>"):
assert guard in src, f"report query missing guard: {guard}"


def test_both_implementations_use_an_anonymous_caller():
"""Having agreed on exclusions, they must also agree on the caller shape."""
report_src = inspect.getsource(report_generator._section_dead_code)
assert "NOT ()-[:CALLS|HEURISTIC_CALLS]->(fn)" in report_src
assert "(caller)-[:CALLS|HEURISTIC_CALLS]->(func)" in _query_src()


# --------------------------------------------------------------- #1559 (3)
def test_cli_forwards_the_path_argument_as_repo_scope():
"""`analyze dead-code .` used to drop the path, so results spanned every
repository in the database."""
from codegraphcontext.cli import main as cli_main

src = inspect.getsource(cli_main.analyze_dead_code)
assert "repo_path=repo_path" in src, "path must be forwarded as repo scope"
assert "not yet implemented" not in src


# --------------------------------------------------------------- #1559 (2)
def test_cli_help_does_not_claim_classes_are_analyzed():
"""The query matches `(func:Function)` only; the help said 'and classes'."""
from codegraphcontext.cli import main as cli_main

doc = inspect.getdoc(cli_main.analyze_dead_code) or ""
assert "classes" not in doc.lower(), "help still claims classes are analyzed"
Loading