chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,852 @@
|
||||
"""Graph-powered refactoring operations.
|
||||
|
||||
Provides rename previews, dead code detection, refactoring suggestions,
|
||||
and safe application of refactoring edits to source files. All file writes
|
||||
go through a preview-then-apply workflow with expiry enforcement and path
|
||||
traversal prevention.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from .flows import _has_framework_decorator, _matches_entry_name
|
||||
from .graph import GraphStore, _sanitize_name
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Base class names that indicate a framework-managed class (ORM models,
|
||||
# Pydantic schemas, settings). Classes inheriting from these are invoked
|
||||
# via metaclass/framework magic and should not be flagged as dead code.
|
||||
_FRAMEWORK_BASE_CLASSES = frozenset({
|
||||
"Base", "DeclarativeBase", "Model", "BaseModel", "BaseSettings",
|
||||
"db.Model", "TableBase",
|
||||
# AWS CDK constructs -- instantiated by CDK app wiring, not explicit CALLS.
|
||||
"Stack", "NestedStack", "Construct", "Resource",
|
||||
})
|
||||
|
||||
# Class name suffixes that indicate CDK/IaC constructs.
|
||||
# These are instantiated by framework wiring, not direct CALLS edges.
|
||||
# Used as fallback when INHERITS edges to external base classes are absent.
|
||||
_CDK_CLASS_SUFFIXES = ("Stack", "Construct", "Pipeline", "Resources", "Layer")
|
||||
|
||||
# Patterns for mock/stub variables in test files that should not be flagged dead.
|
||||
_MOCK_NAME_RE = re.compile(
|
||||
r"^(mock[A-Z_]|Mock[A-Z]|createMock[A-Z])|" # mockDynamoClient, MockService, createMockX
|
||||
r"(Mock|Stub|Fake|Spy)$", # s3ClientMock, dbStub
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread-safe pending refactors storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_refactor_lock = threading.Lock()
|
||||
_pending_refactors: dict[str, dict] = {}
|
||||
REFACTOR_EXPIRY_SECONDS = 600 # 10 minutes
|
||||
|
||||
|
||||
def _cleanup_expired() -> int:
|
||||
"""Remove expired refactors from the pending dict. Returns count removed."""
|
||||
now = time.time()
|
||||
expired = [
|
||||
rid for rid, r in _pending_refactors.items()
|
||||
if now - r["created_at"] > REFACTOR_EXPIRY_SECONDS
|
||||
]
|
||||
for rid in expired:
|
||||
del _pending_refactors[rid]
|
||||
return len(expired)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. rename_preview
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rename_preview(
|
||||
store: GraphStore,
|
||||
old_name: str,
|
||||
new_name: str,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Build a rename edit list for *old_name* -> *new_name*.
|
||||
|
||||
Finds the node via ``store.search_nodes(old_name)``, collects
|
||||
definition and reference sites, generates a unique ``refactor_id``,
|
||||
and stores the preview in the thread-safe ``_pending_refactors`` dict.
|
||||
|
||||
Returns:
|
||||
A refactor preview dict, or ``None`` if the node is not found.
|
||||
"""
|
||||
candidates = store.search_nodes(old_name, limit=10)
|
||||
# Pick the best match: prefer exact name match.
|
||||
node = None
|
||||
for c in candidates:
|
||||
if c.name == old_name:
|
||||
node = c
|
||||
break
|
||||
if node is None and candidates:
|
||||
node = candidates[0]
|
||||
if node is None:
|
||||
logger.warning("rename_preview: node %r not found", old_name)
|
||||
return None
|
||||
|
||||
edits: list[dict[str, Any]] = []
|
||||
|
||||
# --- Definition site ---
|
||||
edits.append({
|
||||
"file": node.file_path,
|
||||
"line": node.line_start,
|
||||
"old": old_name,
|
||||
"new": new_name,
|
||||
"confidence": "high",
|
||||
})
|
||||
|
||||
# --- Call sites (CALLS edges targeting this node) ---
|
||||
call_edges = store.get_edges_by_target(node.qualified_name)
|
||||
for edge in call_edges:
|
||||
if edge.kind == "CALLS":
|
||||
edits.append({
|
||||
"file": edge.file_path,
|
||||
"line": edge.line,
|
||||
"old": old_name,
|
||||
"new": new_name,
|
||||
"confidence": "high",
|
||||
})
|
||||
|
||||
# Also search by bare name for unqualified edges.
|
||||
bare_edges = store.search_edges_by_target_name(old_name, kind="CALLS")
|
||||
seen = {(e["file"], e["line"]) for e in edits}
|
||||
for edge in bare_edges:
|
||||
key = (edge.file_path, edge.line)
|
||||
if key not in seen:
|
||||
edits.append({
|
||||
"file": edge.file_path,
|
||||
"line": edge.line,
|
||||
"old": old_name,
|
||||
"new": new_name,
|
||||
"confidence": "high",
|
||||
})
|
||||
seen.add(key)
|
||||
|
||||
# --- Import sites (IMPORTS_FROM edges targeting this node) ---
|
||||
import_edges = store.get_edges_by_target(node.qualified_name)
|
||||
for edge in import_edges:
|
||||
if edge.kind == "IMPORTS_FROM":
|
||||
key = (edge.file_path, edge.line)
|
||||
if key not in seen:
|
||||
edits.append({
|
||||
"file": edge.file_path,
|
||||
"line": edge.line,
|
||||
"old": old_name,
|
||||
"new": new_name,
|
||||
"confidence": "high",
|
||||
})
|
||||
seen.add(key)
|
||||
|
||||
# --- Stats ---
|
||||
stats = {"high": 0, "medium": 0, "low": 0}
|
||||
for e in edits:
|
||||
stats[e["confidence"]] += 1
|
||||
|
||||
refactor_id = uuid.uuid4().hex[:8]
|
||||
preview: dict[str, Any] = {
|
||||
"refactor_id": refactor_id,
|
||||
"type": "rename",
|
||||
"old_name": _sanitize_name(old_name),
|
||||
"new_name": _sanitize_name(new_name),
|
||||
"edits": edits,
|
||||
"stats": stats,
|
||||
"created_at": time.time(),
|
||||
}
|
||||
|
||||
with _refactor_lock:
|
||||
_cleanup_expired()
|
||||
_pending_refactors[refactor_id] = preview
|
||||
|
||||
logger.info(
|
||||
"rename_preview: created refactor %s (%s -> %s, %d edits)",
|
||||
refactor_id, old_name, new_name, len(edits),
|
||||
)
|
||||
return preview
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. find_dead_code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _is_entry_point(node: Any) -> bool:
|
||||
"""Check if a node looks like an entry point by name or decorator.
|
||||
|
||||
Unlike ``flows.detect_entry_points()`` which treats ALL uncalled functions
|
||||
as entry points, this checks only for conventional name patterns and
|
||||
framework decorators -- the indicators that a function is *intentionally*
|
||||
an entry point rather than simply unreferenced dead code.
|
||||
"""
|
||||
if _has_framework_decorator(node):
|
||||
return True
|
||||
if _matches_entry_name(node):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Matches identifiers inside type annotations (e.g. "GoalCreate" in
|
||||
# "body: GoalCreate", "Optional[UserResponse]", "list[Item]").
|
||||
_TEST_FILE_RE = re.compile(
|
||||
r"([\\/]__tests__[\\/]|\.spec\.[jt]sx?$|\.test\.[jt]sx?$|[\\/]test_[^/\\]*\.py$"
|
||||
r"|[\\/]e2e[_-]?tests?[\\/]|[\\/]test[_-]utils?[\\/])",
|
||||
)
|
||||
|
||||
|
||||
def _is_test_file(file_path: str) -> bool:
|
||||
"""Return True if *file_path* looks like a test file."""
|
||||
return bool(_TEST_FILE_RE.search(file_path))
|
||||
|
||||
|
||||
_MIN_PKG_SEGMENT_LEN = 4 # ignore short dirs like "src", "lib", "app"
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=4096)
|
||||
def _path_segments(file_path: str) -> tuple[str, ...]:
|
||||
"""Return directory segments long enough to serve as package-name anchors."""
|
||||
parts = file_path.replace("\\", "/").split("/")
|
||||
return tuple(
|
||||
p for p in parts[:-1] # skip the filename itself
|
||||
if len(p) >= _MIN_PKG_SEGMENT_LEN and p not in ("home", "src", "lib", "app")
|
||||
)
|
||||
|
||||
|
||||
_TYPE_IDENT_RE = re.compile(r"[A-Z][A-Za-z0-9_]*")
|
||||
|
||||
|
||||
def _collect_type_referenced_names(store: GraphStore) -> set[str]:
|
||||
"""Collect class names that appear in function params or return types."""
|
||||
funcs = store.get_nodes_by_kind(kinds=["Function", "Test"])
|
||||
names: set[str] = set()
|
||||
for f in funcs:
|
||||
for text in (f.params, f.return_type):
|
||||
if text:
|
||||
names.update(_TYPE_IDENT_RE.findall(text))
|
||||
return names
|
||||
|
||||
|
||||
def find_dead_code(
|
||||
store: GraphStore,
|
||||
kind: Optional[str] = None,
|
||||
file_pattern: Optional[str] = None,
|
||||
root: Optional[Union[str, Path]] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Find functions/classes with no callers, no test refs, no importers, and no references.
|
||||
|
||||
Entry points (functions matching framework decorators or conventional name
|
||||
patterns like ``main``, ``test_*``, ``handle_*``) are excluded.
|
||||
|
||||
.. note::
|
||||
|
||||
**Caveats — dynamic dispatch patterns.** Static analysis cannot track
|
||||
all runtime-determined call patterns. Functions registered via fully
|
||||
dynamic keys (``map[computedKey()] = fn``), ``Reflect.apply``, or
|
||||
runtime ``require()`` may still appear as dead code. Treat results as
|
||||
hints, especially for TypeScript projects that use map-based dispatch,
|
||||
plugin registries, or dynamic requires.
|
||||
|
||||
Args:
|
||||
store: The GraphStore instance.
|
||||
kind: Optional filter (e.g. ``"Function"`` or ``"Class"``).
|
||||
file_pattern: Optional file-path substring filter.
|
||||
root: Optional repo root path for computing ``relative_path``.
|
||||
|
||||
Returns:
|
||||
List of dead-code dicts with name, qualified_name, kind, file_path,
|
||||
relative_path, line, and language fields.
|
||||
"""
|
||||
# Query candidate nodes.
|
||||
candidates = store.get_nodes_by_kind(
|
||||
kinds=[kind] if kind else ["Function", "Class"],
|
||||
file_pattern=file_pattern,
|
||||
)
|
||||
|
||||
# Build set of class names referenced in function type annotations.
|
||||
type_ref_names = _collect_type_referenced_names(store)
|
||||
|
||||
# Build class hierarchy: class_qualified_name -> [bare_base_names]
|
||||
class_bases: dict[str, list[str]] = {}
|
||||
conn = store._conn
|
||||
for row in conn.execute(
|
||||
"SELECT source_qualified, target_qualified FROM edges WHERE kind = 'INHERITS'"
|
||||
).fetchall():
|
||||
base = row[1].rsplit("::", 1)[-1] if "::" in row[1] else row[1]
|
||||
class_bases.setdefault(row[0], []).append(base)
|
||||
|
||||
# Build import graph: file_path -> set of file_paths it imports from.
|
||||
# Used to filter bare-name caller matches to plausible callers.
|
||||
importer_files: dict[str, set[str]] = {}
|
||||
for row in conn.execute(
|
||||
"SELECT file_path, target_qualified FROM edges WHERE kind = 'IMPORTS_FROM'"
|
||||
).fetchall():
|
||||
importer_files.setdefault(row[0], set()).add(row[1])
|
||||
|
||||
# Build set of globally unique names (only one non-test node with that name).
|
||||
# For unique names, any bare-name CALLS edge is reliable — no ambiguity.
|
||||
name_counts: dict[str, int] = {}
|
||||
for row in conn.execute(
|
||||
"SELECT name, COUNT(*) FROM nodes "
|
||||
"WHERE kind IN ('Function', 'Class') AND is_test = 0 "
|
||||
"GROUP BY name"
|
||||
).fetchall():
|
||||
name_counts[row[0]] = row[1]
|
||||
|
||||
def _is_plausible_caller(
|
||||
edge_file: str, node_file: str, node_name: str = "",
|
||||
) -> bool:
|
||||
"""A bare-name edge is plausible if it comes from the same file,
|
||||
from a file that has an IMPORTS_FROM edge whose target matches
|
||||
the node's file path, or the name is globally unique (no ambiguity)."""
|
||||
if edge_file == node_file:
|
||||
return True
|
||||
# Unique names (only one definition) have no ambiguity -- accept all callers.
|
||||
if node_name and name_counts.get(node_name, 0) == 1:
|
||||
return True
|
||||
for imp_target in importer_files.get(edge_file, ()):
|
||||
# Strip "::name" suffix — workspace-resolved imports may include it
|
||||
imp_path = imp_target.split("::")[0] if "::" in imp_target else imp_target
|
||||
# __init__.py represents its parent package directory
|
||||
if imp_path.endswith("/__init__.py"):
|
||||
imp_dir = imp_path[:-12] # strip "/__init__.py"
|
||||
if node_file.startswith(imp_dir + "/"):
|
||||
return True
|
||||
if imp_path.startswith(node_file) or node_file.startswith(imp_path + "/"):
|
||||
return True
|
||||
# 2-hop: edge_file imports X, X re-exports from node_file (barrel files)
|
||||
for imp2 in importer_files.get(imp_target, ()):
|
||||
imp2_path = imp2.split("::")[0] if "::" in imp2 else imp2
|
||||
if imp2_path.endswith("/__init__.py"):
|
||||
imp2_dir = imp2_path[:-12]
|
||||
if node_file.startswith(imp2_dir + "/"):
|
||||
return True
|
||||
if imp2_path.startswith(node_file) or node_file.startswith(imp2_path + "/"):
|
||||
return True
|
||||
# Package-alias heuristic: monorepo imports like "@scope/pkg-name"
|
||||
# contain the directory name of the target package. Check if the
|
||||
# import target string contains a significant directory segment from
|
||||
# the node's file path (e.g. "lambda-common" in both the import
|
||||
# "@cova-utils/lambda-common" and the path "libraries/lambda-common/...").
|
||||
if not imp_target.startswith("/"):
|
||||
# imp_target is a package specifier, not a file path
|
||||
for seg in _path_segments(node_file):
|
||||
if seg in imp_target:
|
||||
return True
|
||||
return False
|
||||
|
||||
dead: list[dict[str, Any]] = []
|
||||
|
||||
for node in candidates:
|
||||
|
||||
# Skip test nodes and anything defined in test files.
|
||||
if node.is_test or _is_test_file(node.file_path):
|
||||
continue
|
||||
|
||||
# Skip ambient type declarations (.d.ts) — they describe external APIs.
|
||||
if node.file_path.endswith(".d.ts"):
|
||||
continue
|
||||
|
||||
# Skip dunder methods -- invoked by runtime, never have explicit callers.
|
||||
if node.name.startswith("__") and node.name.endswith("__"):
|
||||
continue
|
||||
|
||||
# Skip JS/TS/Java constructors -- invoked via `new ClassName()`, which
|
||||
# creates a CALLS edge to the class, not to `constructor`.
|
||||
if node.name == "constructor" and node.parent_name:
|
||||
continue
|
||||
|
||||
# Skip mock/stub variables in test files -- these are test helpers
|
||||
# referenced via variable assignment, not function calls.
|
||||
if node.is_test or _is_test_file(node.file_path):
|
||||
if _MOCK_NAME_RE.search(node.name):
|
||||
continue
|
||||
|
||||
# Skip entry points (by name pattern or decorator, not just "uncalled").
|
||||
if _is_entry_point(node):
|
||||
continue
|
||||
|
||||
# Check for callers (CALLS), test refs (TESTED_BY), importers (IMPORTS_FROM),
|
||||
# and value references (REFERENCES -- function-as-value in maps, arrays, etc.).
|
||||
|
||||
# Skip classes referenced in type annotations (Pydantic schemas, etc.).
|
||||
if node.kind == "Class" and node.name in type_ref_names:
|
||||
continue
|
||||
|
||||
# Skip Angular/NestJS decorated classes -- they are framework-managed
|
||||
# and instantiated by the DI container, not direct CALLS edges.
|
||||
if node.kind == "Class" and _has_framework_decorator(node):
|
||||
continue
|
||||
|
||||
# Skip classes (and their methods) inheriting from known framework bases.
|
||||
_is_framework_class = False
|
||||
_check_qn = node.qualified_name if node.kind == "Class" else (
|
||||
node.qualified_name.rsplit(".", 1)[0] if node.parent_name else None
|
||||
)
|
||||
if _check_qn:
|
||||
outgoing = store.get_edges_by_source(_check_qn)
|
||||
base_names = {
|
||||
e.target_qualified.rsplit("::", 1)[-1]
|
||||
for e in outgoing if e.kind == "INHERITS"
|
||||
}
|
||||
if base_names & _FRAMEWORK_BASE_CLASSES:
|
||||
_is_framework_class = True
|
||||
if node.kind == "Class":
|
||||
if _is_framework_class:
|
||||
continue
|
||||
# Fallback: CDK class name suffixes (no INHERITS edge for external bases)
|
||||
if any(node.name.endswith(s) for s in _CDK_CLASS_SUFFIXES):
|
||||
continue
|
||||
if node.kind == "Function" and _is_framework_class:
|
||||
continue
|
||||
# Also skip methods whose parent class name matches CDK suffixes
|
||||
# (fallback for external base classes without INHERITS edges).
|
||||
if (
|
||||
node.kind == "Function"
|
||||
and node.parent_name
|
||||
and any(node.parent_name.endswith(s) for s in _CDK_CLASS_SUFFIXES)
|
||||
):
|
||||
continue
|
||||
|
||||
# Skip decorated functions/classes that are invoked implicitly rather
|
||||
# than via explicit CALLS edges.
|
||||
decorators = node.extra.get("decorators", ())
|
||||
if isinstance(decorators, (list, tuple)) and decorators:
|
||||
if node.kind in ("Function", "Test"):
|
||||
# @property -- invoked via attribute access
|
||||
# @abstractmethod -- polymorphic dispatch, never called directly
|
||||
# @classmethod/@staticmethod -- called via Class.method()
|
||||
if any(
|
||||
d in ("property", "abstractmethod", "classmethod", "staticmethod")
|
||||
or d.endswith(".abstractmethod")
|
||||
# Angular @HostListener -- method called by framework event system
|
||||
or d.startswith("HostListener")
|
||||
for d in decorators
|
||||
):
|
||||
continue
|
||||
if node.kind == "Class":
|
||||
# @dataclass classes are instantiated as types, not via CALLS
|
||||
if any("dataclass" in d for d in decorators):
|
||||
continue
|
||||
|
||||
# Skip methods that override an @abstractmethod in a base class --
|
||||
# they are called polymorphically via the base class reference.
|
||||
if node.kind == "Function" and node.parent_name:
|
||||
parent_qn = node.qualified_name.rsplit(".", 1)[0]
|
||||
parent_edges = store.get_edges_by_source(parent_qn)
|
||||
base_class_names = [
|
||||
e.target_qualified for e in parent_edges if e.kind == "INHERITS"
|
||||
]
|
||||
for base_name in base_class_names:
|
||||
# Try fully-qualified base first, then bare name match
|
||||
base_method_qn = f"{base_name}.{node.name}"
|
||||
base_nodes = store.get_node(base_method_qn)
|
||||
if base_nodes is None:
|
||||
# Base class may be bare name -- search in same file
|
||||
base_method_qn2 = (
|
||||
node.file_path + "::" + base_name + "." + node.name
|
||||
)
|
||||
base_nodes = store.get_node(base_method_qn2)
|
||||
if base_nodes is not None:
|
||||
base_decos = base_nodes.extra.get("decorators", ())
|
||||
if isinstance(base_decos, (list, tuple)) and any(
|
||||
"abstractmethod" in d for d in base_decos
|
||||
):
|
||||
break
|
||||
else:
|
||||
base_name = None # no abstract override found
|
||||
if base_name is not None:
|
||||
continue
|
||||
|
||||
incoming = store.get_edges_by_target(node.qualified_name)
|
||||
# Also check class-qualified edges (e.g. "ClassName::method") which
|
||||
# lack the file-path prefix used in node.qualified_name.
|
||||
if not any(e.kind == "CALLS" for e in incoming) and node.parent_name:
|
||||
class_qn = f"{node.parent_name}::{node.name}"
|
||||
incoming = incoming + store.get_edges_by_target(class_qn)
|
||||
# Also check bare-name and partially-qualified edges.
|
||||
# CALLS targets may be bare ("funcName"), class-qualified
|
||||
# ("Class::method"), or workspace-qualified ("pkg/dir::funcName").
|
||||
if not any(e.kind == "CALLS" for e in incoming):
|
||||
bare = store.search_edges_by_target_name(node.name, kind="CALLS")
|
||||
# Also search for partially-qualified targets ending with ::name
|
||||
suffix_rows = conn.execute(
|
||||
"SELECT * FROM edges WHERE kind = 'CALLS'"
|
||||
" AND target_qualified LIKE ?",
|
||||
(f"%::{node.name}",),
|
||||
).fetchall()
|
||||
suffix_edges = [store._row_to_edge(r) for r in suffix_rows]
|
||||
all_bare = bare + suffix_edges
|
||||
all_bare = [
|
||||
e for e in all_bare
|
||||
if _is_plausible_caller(e.file_path, node.file_path, node.name)
|
||||
]
|
||||
incoming = incoming + all_bare
|
||||
if not any(e.kind == "TESTED_BY" for e in incoming):
|
||||
bare_tb = store.search_edges_by_target_name(node.name, kind="TESTED_BY")
|
||||
bare_tb = [
|
||||
e for e in bare_tb
|
||||
if _is_plausible_caller(e.file_path, node.file_path, node.name)
|
||||
]
|
||||
incoming = incoming + bare_tb
|
||||
# Check INHERITS -- classes with subclasses are not dead.
|
||||
if node.kind == "Class" and not any(e.kind == "INHERITS" for e in incoming):
|
||||
bare_inh = store.search_edges_by_target_name(node.name, kind="INHERITS")
|
||||
incoming = incoming + bare_inh
|
||||
has_callers = any(e.kind == "CALLS" for e in incoming)
|
||||
has_test_refs = any(e.kind == "TESTED_BY" for e in incoming)
|
||||
has_importers = any(e.kind == "IMPORTS_FROM" for e in incoming)
|
||||
has_references = any(e.kind == "REFERENCES" for e in incoming)
|
||||
has_subclasses = any(e.kind == "INHERITS" for e in incoming)
|
||||
|
||||
# For classes with no direct references, check if any member has callers.
|
||||
no_refs = not (
|
||||
has_callers or has_test_refs or has_importers
|
||||
or has_references or has_subclasses
|
||||
)
|
||||
if node.kind == "Class" and no_refs:
|
||||
member_prefix = node.qualified_name + "."
|
||||
# Also check bare class-name pattern (unresolved CALLS targets)
|
||||
bare_prefix = node.name + "."
|
||||
member_calls = conn.execute(
|
||||
"SELECT COUNT(*) FROM edges WHERE kind = 'CALLS'"
|
||||
" AND (target_qualified LIKE ? OR target_qualified LIKE ?)",
|
||||
(f"%{member_prefix}%", f"%{bare_prefix}%"),
|
||||
).fetchone()[0]
|
||||
if member_calls > 0:
|
||||
has_callers = True
|
||||
|
||||
if not (
|
||||
has_callers or has_test_refs or has_importers
|
||||
or has_references or has_subclasses
|
||||
):
|
||||
# Check if this is a method override where the base class method
|
||||
# has callers (polymorphic dispatch: callers of Base.method()
|
||||
# implicitly call SubClass.method() at runtime).
|
||||
if node.kind == "Function" and node.parent_name and not has_callers:
|
||||
method_suffix = "." + node.name
|
||||
if node.qualified_name.endswith(method_suffix):
|
||||
class_qn = node.qualified_name[: -len(method_suffix)]
|
||||
for base_name in class_bases.get(class_qn, []):
|
||||
rows = conn.execute(
|
||||
"SELECT n.qualified_name FROM nodes n "
|
||||
"WHERE n.parent_name = ? AND n.name = ? "
|
||||
"AND n.kind IN ('Function', 'Test')",
|
||||
(base_name, node.name),
|
||||
).fetchall()
|
||||
for (base_method_qn,) in rows:
|
||||
if conn.execute(
|
||||
"SELECT 1 FROM edges "
|
||||
"WHERE target_qualified = ? AND kind = 'CALLS' "
|
||||
"LIMIT 1",
|
||||
(base_method_qn,),
|
||||
).fetchone():
|
||||
has_callers = True
|
||||
break
|
||||
if has_callers:
|
||||
break
|
||||
|
||||
if not has_callers:
|
||||
if root:
|
||||
try:
|
||||
rel = str(Path(node.file_path).relative_to(root))
|
||||
except ValueError:
|
||||
rel = node.file_path
|
||||
else:
|
||||
rel = node.file_path
|
||||
dead.append({
|
||||
"name": _sanitize_name(node.name),
|
||||
"qualified_name": _sanitize_name(node.qualified_name),
|
||||
"kind": node.kind,
|
||||
"file": node.file_path,
|
||||
"file_path": node.file_path,
|
||||
"relative_path": rel,
|
||||
"line": node.line_start,
|
||||
"language": node.language,
|
||||
})
|
||||
|
||||
logger.info("find_dead_code: found %d dead symbols", len(dead))
|
||||
return dead
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. suggest_refactorings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def suggest_refactorings(store: GraphStore) -> list[dict[str, Any]]:
|
||||
"""Produce community-driven refactoring suggestions.
|
||||
|
||||
Currently two categories:
|
||||
- **move**: Functions in Community A only called by Community B.
|
||||
- **remove**: Dead code (no callers, tests, or importers and not entry points).
|
||||
|
||||
Returns:
|
||||
List of suggestion dicts with type, description, symbols, rationale.
|
||||
"""
|
||||
suggestions: list[dict[str, Any]] = []
|
||||
|
||||
# --- Dead code suggestions ---
|
||||
dead = find_dead_code(store)
|
||||
for d in dead:
|
||||
suggestions.append({
|
||||
"type": "remove",
|
||||
"description": f"Remove unused {d['kind'].lower()} '{d['name']}'",
|
||||
"symbols": [d["qualified_name"]],
|
||||
"rationale": "No callers, no test references, no importers, not an entry point.",
|
||||
})
|
||||
|
||||
# --- Cross-community move suggestions ---
|
||||
# Only attempt if communities table exists and has data.
|
||||
community_rows = store.get_communities_list()
|
||||
|
||||
if community_rows:
|
||||
# Build node -> community_id mapping.
|
||||
node_community: dict[str, int] = {}
|
||||
for crow in community_rows:
|
||||
cid = crow["id"]
|
||||
member_qns = store.get_community_member_qns(cid)
|
||||
for qn in member_qns:
|
||||
node_community[qn] = cid
|
||||
|
||||
community_names: dict[int, str] = {
|
||||
r["id"]: r["name"] for r in community_rows
|
||||
}
|
||||
|
||||
# Check functions called only by members of a different community.
|
||||
all_funcs = store.get_nodes_by_kind(["Function"])
|
||||
|
||||
for fnode in all_funcs:
|
||||
f_community = node_community.get(fnode.qualified_name)
|
||||
if f_community is None:
|
||||
continue
|
||||
|
||||
incoming_calls = [
|
||||
e for e in store.get_edges_by_target(fnode.qualified_name)
|
||||
if e.kind == "CALLS"
|
||||
]
|
||||
if not incoming_calls:
|
||||
continue
|
||||
|
||||
caller_communities = set()
|
||||
for edge in incoming_calls:
|
||||
c_community = node_community.get(edge.source_qualified)
|
||||
if c_community is not None:
|
||||
caller_communities.add(c_community)
|
||||
|
||||
# If ALL callers are from a single *different* community, suggest move.
|
||||
if len(caller_communities) == 1:
|
||||
target_community = next(iter(caller_communities))
|
||||
if target_community != f_community:
|
||||
src_name = community_names.get(f_community, f"community-{f_community}")
|
||||
tgt_name = community_names.get(
|
||||
target_community, f"community-{target_community}"
|
||||
)
|
||||
suggestions.append({
|
||||
"type": "move",
|
||||
"description": (
|
||||
f"Move '{_sanitize_name(fnode.name)}' from "
|
||||
f"'{src_name}' to '{tgt_name}'"
|
||||
),
|
||||
"symbols": [_sanitize_name(fnode.qualified_name)],
|
||||
"rationale": (
|
||||
f"Function is in community '{src_name}' but only "
|
||||
f"called by members of community '{tgt_name}'."
|
||||
),
|
||||
})
|
||||
|
||||
logger.info("suggest_refactorings: produced %d suggestions", len(suggestions))
|
||||
return suggestions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. apply_refactor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def apply_refactor(
|
||||
refactor_id: str,
|
||||
repo_root: Path,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Apply a previously previewed refactoring to source files.
|
||||
|
||||
Validates the refactor_id, checks expiry, ensures all edit paths are
|
||||
within the repo root, then performs exact string replacements on the
|
||||
target files.
|
||||
|
||||
Args:
|
||||
refactor_id: ID from a prior ``rename_preview`` call.
|
||||
repo_root: Validated repository root path.
|
||||
dry_run: If True, compute the would-be changes and return a
|
||||
unified-diff representation per affected file, but do NOT
|
||||
write anything to disk. The ``refactor_id`` is preserved so
|
||||
the same preview can be committed afterwards via a second
|
||||
call without ``dry_run``. See: #176
|
||||
|
||||
Returns:
|
||||
Status dict with applied count and modified files. When
|
||||
``dry_run=True`` the dict additionally contains:
|
||||
|
||||
- ``dry_run``: ``True``
|
||||
- ``would_modify``: list of file paths that would be changed
|
||||
- ``diffs``: map of file path → unified diff string showing the
|
||||
proposed change
|
||||
"""
|
||||
repo_root = repo_root.resolve()
|
||||
|
||||
with _refactor_lock:
|
||||
_cleanup_expired()
|
||||
preview = _pending_refactors.get(refactor_id)
|
||||
|
||||
if preview is None:
|
||||
logger.warning("apply_refactor: unknown or expired refactor_id %s", refactor_id)
|
||||
return {"status": "error", "error": f"Refactor '{refactor_id}' not found or expired."}
|
||||
|
||||
# Check expiry explicitly.
|
||||
age = time.time() - preview["created_at"]
|
||||
if age > REFACTOR_EXPIRY_SECONDS:
|
||||
with _refactor_lock:
|
||||
_pending_refactors.pop(refactor_id, None)
|
||||
logger.warning("apply_refactor: refactor %s expired (%.0fs old)", refactor_id, age)
|
||||
return {"status": "error", "error": f"Refactor '{refactor_id}' has expired."}
|
||||
|
||||
edits = preview.get("edits", [])
|
||||
if not edits:
|
||||
if dry_run:
|
||||
return {
|
||||
"status": "ok", "dry_run": True, "applied": 0,
|
||||
"files_modified": [], "edits_applied": 0,
|
||||
"would_modify": [], "diffs": {},
|
||||
}
|
||||
return {"status": "ok", "applied": 0, "files_modified": [], "edits_applied": 0}
|
||||
|
||||
# --- Path traversal validation ---
|
||||
for edit in edits:
|
||||
edit_path = Path(edit["file"]).resolve()
|
||||
try:
|
||||
edit_path.relative_to(repo_root)
|
||||
except ValueError:
|
||||
logger.error(
|
||||
"apply_refactor: path traversal blocked for %s (repo_root=%s)",
|
||||
edit_path, repo_root,
|
||||
)
|
||||
return {
|
||||
"status": "error",
|
||||
"error": f"Edit path '{edit['file']}' is outside repo root.",
|
||||
}
|
||||
|
||||
# --- Compute new content for every edit (shared by dry-run and write paths) ---
|
||||
# Group edits by file so multiple edits to the same file apply
|
||||
# sequentially against the updated content rather than stomping each
|
||||
# other. Dry-run and write modes then share this computation.
|
||||
from collections import defaultdict
|
||||
edits_by_file: dict[str, list[dict]] = defaultdict(list)
|
||||
for edit in edits:
|
||||
edits_by_file[edit["file"]].append(edit)
|
||||
|
||||
planned: dict[str, tuple[str, str, int]] = {} # file -> (old_content, new_content, edit_count)
|
||||
for file_str, file_edits in edits_by_file.items():
|
||||
file_path = Path(file_str)
|
||||
if not file_path.is_file():
|
||||
logger.warning("apply_refactor: file not found: %s", file_path)
|
||||
continue
|
||||
try:
|
||||
original = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning("apply_refactor: could not read %s: %s", file_path, exc)
|
||||
continue
|
||||
|
||||
content = original
|
||||
file_edits_applied = 0
|
||||
for edit in file_edits:
|
||||
old_text = edit["old"]
|
||||
new_text = edit["new"]
|
||||
if old_text not in content:
|
||||
logger.warning(
|
||||
"apply_refactor: old text %r not found in %s",
|
||||
old_text, file_path,
|
||||
)
|
||||
continue
|
||||
target_line = edit.get("line")
|
||||
if target_line is not None:
|
||||
lines = content.splitlines(keepends=True)
|
||||
idx = target_line - 1
|
||||
if 0 <= idx < len(lines) and old_text in lines[idx]:
|
||||
lines[idx] = lines[idx].replace(old_text, new_text, 1)
|
||||
content = "".join(lines)
|
||||
else:
|
||||
content = content.replace(old_text, new_text, 1)
|
||||
else:
|
||||
content = content.replace(old_text, new_text, 1)
|
||||
file_edits_applied += 1
|
||||
|
||||
if file_edits_applied > 0:
|
||||
planned[file_str] = (original, content, file_edits_applied)
|
||||
|
||||
# --- Dry-run path: return diffs, no writes ---
|
||||
if dry_run:
|
||||
import difflib
|
||||
diffs: dict[str, str] = {}
|
||||
for file_str, (original, new_content, _count) in planned.items():
|
||||
diff_lines = list(difflib.unified_diff(
|
||||
original.splitlines(keepends=True),
|
||||
new_content.splitlines(keepends=True),
|
||||
fromfile=f"a/{file_str}",
|
||||
tofile=f"b/{file_str}",
|
||||
n=3,
|
||||
))
|
||||
diffs[file_str] = "".join(diff_lines)
|
||||
total_edits = sum(count for _o, _n, count in planned.values())
|
||||
result = {
|
||||
"status": "ok",
|
||||
"dry_run": True,
|
||||
"applied": 0,
|
||||
"edits_applied": total_edits,
|
||||
"would_modify": sorted(planned.keys()),
|
||||
"files_modified": [],
|
||||
"diffs": diffs,
|
||||
}
|
||||
logger.info(
|
||||
"apply_refactor: dry-run %s — %d edits would be applied to %d files",
|
||||
refactor_id, total_edits, len(planned),
|
||||
)
|
||||
# Do NOT pop the pending refactor — let the user commit via a
|
||||
# second call with dry_run=False.
|
||||
return result
|
||||
|
||||
# --- Real-write path: write the pre-computed new content ---
|
||||
files_modified: set[str] = set()
|
||||
edits_applied = 0
|
||||
for file_str, (_original, new_content, count) in planned.items():
|
||||
file_path = Path(file_str)
|
||||
try:
|
||||
file_path.write_text(new_content, encoding="utf-8")
|
||||
edits_applied += count
|
||||
files_modified.add(str(file_path))
|
||||
logger.info("apply_refactor: applied %d edit(s) to %s", count, file_path)
|
||||
except OSError as exc:
|
||||
logger.error("apply_refactor: could not write %s: %s", file_path, exc)
|
||||
|
||||
# Remove from pending after successful application.
|
||||
with _refactor_lock:
|
||||
_pending_refactors.pop(refactor_id, None)
|
||||
|
||||
result = {
|
||||
"status": "ok",
|
||||
"applied": edits_applied,
|
||||
"files_modified": sorted(files_modified),
|
||||
"edits_applied": edits_applied,
|
||||
}
|
||||
logger.info("apply_refactor: completed %s — %d edits applied", refactor_id, edits_applied)
|
||||
return result
|
||||
Reference in New Issue
Block a user