"""Tests for graph-powered refactoring operations.""" import tempfile import threading import time from pathlib import Path from code_review_graph.graph import GraphStore from code_review_graph.parser import CodeParser, EdgeInfo, NodeInfo from code_review_graph.refactor import ( REFACTOR_EXPIRY_SECONDS, _pending_refactors, _refactor_lock, apply_refactor, find_dead_code, rename_preview, suggest_refactorings, ) class TestRenamePreview: """Tests for rename_preview.""" def setup_method(self): self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) self.store = GraphStore(self.tmp.name) self._seed() def teardown_method(self): self.store.close() Path(self.tmp.name).unlink(missing_ok=True) # Clean up pending refactors. with _refactor_lock: _pending_refactors.clear() def _seed(self): """Seed the store with test data for rename tests.""" # File nodes self.store.upsert_node(NodeInfo( kind="File", name="/repo/utils.py", file_path="/repo/utils.py", line_start=1, line_end=50, language="python", )) self.store.upsert_node(NodeInfo( kind="File", name="/repo/main.py", file_path="/repo/main.py", line_start=1, line_end=30, language="python", )) # Function to rename self.store.upsert_node(NodeInfo( kind="Function", name="helper", file_path="/repo/utils.py", line_start=10, line_end=20, language="python", )) # Caller function self.store.upsert_node(NodeInfo( kind="Function", name="run", file_path="/repo/main.py", line_start=5, line_end=15, language="python", )) # CALLS edge: run -> helper self.store.upsert_edge(EdgeInfo( kind="CALLS", source="/repo/main.py::run", target="/repo/utils.py::helper", file_path="/repo/main.py", line=10, )) # IMPORTS_FROM edge: main.py imports helper self.store.upsert_edge(EdgeInfo( kind="IMPORTS_FROM", source="/repo/main.py", target="/repo/utils.py::helper", file_path="/repo/main.py", line=1, )) self.store.commit() def test_rename_preview_returns_edits_with_refactor_id(self): """rename_preview returns a dict with refactor_id and edits.""" result = rename_preview(self.store, "helper", "new_helper") assert result is not None assert "refactor_id" in result assert len(result["refactor_id"]) == 8 assert result["type"] == "rename" assert result["old_name"] == "helper" assert result["new_name"] == "new_helper" assert isinstance(result["edits"], list) assert len(result["edits"]) > 0 assert "stats" in result assert result["stats"]["high"] > 0 def test_rename_finds_callers(self): """rename_preview finds definition + call sites.""" result = rename_preview(self.store, "helper", "new_helper") assert result is not None edits = result["edits"] # Should have at least: 1 definition + 1 call + 1 import = 3 assert len(edits) >= 3 files = {e["file"] for e in edits} assert "/repo/utils.py" in files # definition assert "/repo/main.py" in files # call site + import site def test_rename_not_found(self): """rename_preview returns None if symbol not found.""" result = rename_preview(self.store, "nonexistent_function", "new_name") assert result is None def test_rename_stores_in_pending(self): """rename_preview stores the preview in _pending_refactors.""" result = rename_preview(self.store, "helper", "new_helper") assert result is not None rid = result["refactor_id"] with _refactor_lock: assert rid in _pending_refactors class TestFindDeadCode: """Tests for find_dead_code.""" def setup_method(self): self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) self.store = GraphStore(self.tmp.name) self._seed() def teardown_method(self): self.store.close() Path(self.tmp.name).unlink(missing_ok=True) def _seed(self): """Seed with a mix of used and unused functions.""" # File self.store.upsert_node(NodeInfo( kind="File", name="/repo/app.py", file_path="/repo/app.py", line_start=1, line_end=100, language="python", )) # A function that IS called self.store.upsert_node(NodeInfo( kind="Function", name="used_func", file_path="/repo/app.py", line_start=10, line_end=20, language="python", )) # A function that is NOT called (dead code) self.store.upsert_node(NodeInfo( kind="Function", name="dead_func", file_path="/repo/app.py", line_start=30, line_end=40, language="python", )) # An entry point function (should be excluded) self.store.upsert_node(NodeInfo( kind="Function", name="main", file_path="/repo/app.py", line_start=50, line_end=60, language="python", )) # A test function (should be excluded) self.store.upsert_node(NodeInfo( kind="Test", name="test_something", file_path="/repo/test_app.py", line_start=1, line_end=10, language="python", is_test=True, )) # Caller for used_func self.store.upsert_node(NodeInfo( kind="Function", name="caller", file_path="/repo/app.py", line_start=70, line_end=80, language="python", )) self.store.upsert_edge(EdgeInfo( kind="CALLS", source="/repo/app.py::caller", target="/repo/app.py::used_func", file_path="/repo/app.py", line=75, )) self.store.commit() def test_find_dead_code(self): """find_dead_code detects unreferenced functions.""" dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "dead_func" in dead_names def test_find_dead_code_response_fields(self): """dead_code entries include file_path, relative_path, and language.""" dead = find_dead_code(self.store, root="/repo") entry = next(d for d in dead if d["name"] == "dead_func") assert entry["file_path"] == "/repo/app.py" assert entry["relative_path"] == "app.py" assert entry["language"] == "python" # backward compat: 'file' key still present assert entry["file"] == "/repo/app.py" def test_find_dead_code_relative_path_without_root(self): """Without root, relative_path falls back to file_path.""" dead = find_dead_code(self.store) entry = next(d for d in dead if d["name"] == "dead_func") assert entry["relative_path"] == "/repo/app.py" def test_find_dead_code_excludes_called(self): """find_dead_code does NOT include functions with callers.""" dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "used_func" not in dead_names def test_find_dead_code_excludes_entry_points(self): """Entry points (like 'main') are not flagged as dead code.""" dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "main" not in dead_names def test_find_dead_code_excludes_tests(self): """Test nodes are not flagged as dead code.""" dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "test_something" not in dead_names def test_find_dead_code_kind_filter(self): """kind filter restricts results.""" dead = find_dead_code(self.store, kind="Class") # We have no Class nodes, so should be empty assert len(dead) == 0 def test_find_dead_code_file_pattern(self): """file_pattern filter works.""" dead = find_dead_code(self.store, file_pattern="nonexistent") assert len(dead) == 0 def test_find_dead_code_excludes_dunder(self): """Dunder methods are not flagged as dead code.""" self.store.upsert_node(NodeInfo( kind="Function", name="__init__", file_path="/repo/app.py", line_start=90, line_end=95, language="python", parent_name="MyClass", )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "__init__" not in dead_names def test_find_dead_code_excludes_constructor(self): """JS/TS constructors are not flagged as dead code.""" self.store.upsert_node(NodeInfo( kind="Function", name="constructor", file_path="/repo/component.ts", line_start=10, line_end=15, language="typescript", parent_name="MyComponent", )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "constructor" not in dead_names def test_find_dead_code_excludes_angular_lifecycle(self): """Angular lifecycle hooks are not flagged as dead code.""" for name in ("ngOnInit", "ngOnChanges", "ngOnDestroy", "transform", "writeValue", "canActivate"): self.store.upsert_node(NodeInfo( kind="Function", name=name, file_path="/repo/component.ts", line_start=10, line_end=15, language="typescript", parent_name="MyComponent", )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} for name in ("ngOnInit", "ngOnChanges", "ngOnDestroy", "transform", "writeValue", "canActivate"): assert name not in dead_names, f"{name} should not be dead" def test_find_dead_code_excludes_decorated_entry(self): """Functions with framework decorators are not flagged as dead code.""" self.store.upsert_node(NodeInfo( kind="Function", name="get_users", file_path="/repo/app.py", line_start=90, line_end=95, language="python", extra={"decorators": ["app.get('/users')"]}, )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "get_users" not in dead_names def test_find_dead_code_excludes_type_referenced_class(self): """Classes referenced in function type annotations are not dead code.""" self.store.upsert_node(NodeInfo( kind="Class", name="UserSchema", file_path="/repo/app.py", line_start=5, line_end=15, language="python", )) # A function that uses UserSchema in its params self.store.upsert_node(NodeInfo( kind="Function", name="create_user", file_path="/repo/app.py", line_start=20, line_end=30, language="python", params="body: UserSchema", )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "UserSchema" not in dead_names def test_find_dead_code_excludes_return_type_reference(self): """Classes referenced in return types are not dead code.""" self.store.upsert_node(NodeInfo( kind="Class", name="UserResponse", file_path="/repo/app.py", line_start=5, line_end=15, language="python", )) self.store.upsert_node(NodeInfo( kind="Function", name="get_user", file_path="/repo/app.py", line_start=20, line_end=30, language="python", return_type="Optional[UserResponse]", )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "UserResponse" not in dead_names def test_find_dead_code_excludes_orm_model(self): """Classes inheriting from known ORM bases are not dead code.""" self.store.upsert_node(NodeInfo( kind="Class", name="User", file_path="/repo/app.py", line_start=5, line_end=20, language="python", )) self.store.upsert_edge(EdgeInfo( kind="INHERITS", source="/repo/app.py::User", target="Base", file_path="/repo/app.py", line=5, )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "User" not in dead_names def test_find_dead_code_excludes_pydantic_settings(self): """Classes inheriting from BaseSettings are not dead code.""" self.store.upsert_node(NodeInfo( kind="Class", name="AppConfig", file_path="/repo/app.py", line_start=5, line_end=15, language="python", )) self.store.upsert_edge(EdgeInfo( kind="INHERITS", source="/repo/app.py::AppConfig", target="BaseSettings", file_path="/repo/app.py", line=5, )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "AppConfig" not in dead_names def test_find_dead_code_excludes_agent_tool(self): """Functions with @agent.tool decorator are not dead code.""" self.store.upsert_node(NodeInfo( kind="Function", name="query_data", file_path="/repo/app.py", line_start=10, line_end=20, language="python", extra={"decorators": ["health_agent.tool"]}, )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "query_data" not in dead_names def test_find_dead_code_excludes_alembic_upgrade(self): """upgrade() and downgrade() in alembic files are not dead code.""" self.store.upsert_node(NodeInfo( kind="Function", name="upgrade", file_path="/repo/alembic/versions/001.py", line_start=5, line_end=15, language="python", )) self.store.upsert_node(NodeInfo( kind="Function", name="downgrade", file_path="/repo/alembic/versions/001.py", line_start=20, line_end=30, language="python", )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "upgrade" not in dead_names assert "downgrade" not in dead_names def test_find_dead_code_excludes_subclassed_class(self): """Classes with subclasses (INHERITS edges) are not dead code.""" self.store.upsert_node(NodeInfo( kind="Class", name="BaseConnector", file_path="/repo/connectors.py", line_start=5, line_end=50, language="python", )) # A subclass inherits from BaseConnector (bare-name target) self.store.upsert_edge(EdgeInfo( kind="INHERITS", source="/repo/connectors.py::GarminConnector", target="BaseConnector", file_path="/repo/connectors.py", line=60, )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "BaseConnector" not in dead_names def test_find_dead_code_bare_name_not_tricked_by_unrelated_caller(self): """Bare-name CALLS from unrelated files don't save a dead function when there are multiple definitions with the same name.""" # Two unrelated functions named "processor" in different files self.store.upsert_node(NodeInfo( kind="Function", name="processor", file_path="/repo/api/routes.py", line_start=10, line_end=20, language="python", )) self.store.upsert_node(NodeInfo( kind="Function", name="processor", file_path="/repo/worker/tasks.py", line_start=10, line_end=20, language="python", )) # A bare CALLS edge from a third file that imports only routes.py self.store.upsert_edge(EdgeInfo( kind="IMPORTS_FROM", source="/repo/main.py", target="/repo/api/routes.py", file_path="/repo/main.py", line=1, )) self.store.upsert_edge(EdgeInfo( kind="CALLS", source="/repo/main.py::start", target="processor", file_path="/repo/main.py", line=10, )) self.store.commit() dead = find_dead_code(self.store) dead_qnames = {d["qualified_name"] for d in dead} # routes.py processor is saved (caller imports its file) assert "/repo/api/routes.py::processor" not in dead_qnames # worker/tasks.py processor is dead (no relationship with caller) assert "/repo/worker/tasks.py::processor" in dead_qnames def test_find_dead_code_excludes_mock_variables(self): """Mock/stub variables in test files are not flagged as dead code.""" for name in ("mockDynamoClient", "s3ClientMock", "MockService", "createMockRequest"): self.store.upsert_node(NodeInfo( kind="Function", name=name, file_path="/repo/tests/handler.spec.ts", line_start=10, line_end=15, language="typescript", )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} for name in ("mockDynamoClient", "s3ClientMock", "MockService", "createMockRequest"): assert name not in dead_names, f"{name} should not be dead (mock pattern)" def test_find_dead_code_excludes_angular_decorated_class(self): """Angular @Component classes are not flagged as dead code.""" self.store.upsert_node(NodeInfo( kind="Class", name="ClipboardButtonComponent", file_path="/repo/src/app/clipboard.component.ts", line_start=5, line_end=50, language="typescript", extra={"decorators": ["Component({selector: 'app-clipboard'})"]}, )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "ClipboardButtonComponent" not in dead_names def test_find_dead_code_excludes_property(self): """Functions decorated with @property are not dead code.""" self.store.upsert_node(NodeInfo( kind="Function", name="db", file_path="/repo/deps.py", line_start=10, line_end=15, language="python", extra={"decorators": ["property"]}, )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "db" not in dead_names class TestSuggestRefactorings: """Tests for suggest_refactorings.""" def setup_method(self): self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) self.store = GraphStore(self.tmp.name) self._seed() def teardown_method(self): self.store.close() Path(self.tmp.name).unlink(missing_ok=True) def _seed(self): """Seed with dead code to generate suggestions.""" self.store.upsert_node(NodeInfo( kind="File", name="/repo/lib.py", file_path="/repo/lib.py", line_start=1, line_end=50, language="python", )) # Unreferenced function -> removal suggestion self.store.upsert_node(NodeInfo( kind="Function", name="orphan_func", file_path="/repo/lib.py", line_start=10, line_end=20, language="python", )) self.store.commit() def test_suggest_refactorings(self): """suggest_refactorings returns a list of suggestions.""" suggestions = suggest_refactorings(self.store) assert isinstance(suggestions, list) # Should have at least the dead-code removal suggestion assert len(suggestions) >= 1 types = {s["type"] for s in suggestions} assert "remove" in types def test_suggestion_structure(self): """Each suggestion has the required fields.""" suggestions = suggest_refactorings(self.store) for s in suggestions: assert "type" in s assert "description" in s assert "symbols" in s assert "rationale" in s assert s["type"] in ("move", "remove") class TestApplyRefactor: """Tests for apply_refactor.""" def setup_method(self): with _refactor_lock: _pending_refactors.clear() def teardown_method(self): with _refactor_lock: _pending_refactors.clear() def test_apply_refactor_validates_id(self): """apply_refactor rejects nonexistent refactor_id.""" # Use a real temp dir as repo_root (needs .git or .code-review-graph) tmp_dir = Path(tempfile.mkdtemp()) (tmp_dir / ".git").mkdir() try: result = apply_refactor("nonexistent_id", tmp_dir) assert result["status"] == "error" assert "not found" in result["error"].lower() or "expired" in result["error"].lower() finally: (tmp_dir / ".git").rmdir() tmp_dir.rmdir() def test_apply_refactor_expiry(self): """apply_refactor rejects expired previews.""" tmp_dir = Path(tempfile.mkdtemp()) (tmp_dir / ".git").mkdir() try: # Insert a preview that is already expired. rid = "expired1" with _refactor_lock: _pending_refactors[rid] = { "refactor_id": rid, "type": "rename", "old_name": "old", "new_name": "new", "edits": [], "stats": {"high": 0, "medium": 0, "low": 0}, "created_at": time.time() - REFACTOR_EXPIRY_SECONDS - 10, } result = apply_refactor(rid, tmp_dir) assert result["status"] == "error" assert "expired" in result["error"].lower() finally: (tmp_dir / ".git").rmdir() tmp_dir.rmdir() def test_apply_refactor_path_traversal(self): """apply_refactor blocks edits outside repo root.""" tmp_dir = Path(tempfile.mkdtemp()) (tmp_dir / ".git").mkdir() try: rid = "traversal" with _refactor_lock: _pending_refactors[rid] = { "refactor_id": rid, "type": "rename", "old_name": "old", "new_name": "new", "edits": [{ "file": "/etc/passwd", "line": 1, "old": "old", "new": "new", "confidence": "high", }], "stats": {"high": 1, "medium": 0, "low": 0}, "created_at": time.time(), } result = apply_refactor(rid, tmp_dir) assert result["status"] == "error" assert "outside repo root" in result["error"].lower() finally: (tmp_dir / ".git").rmdir() tmp_dir.rmdir() def test_apply_refactor_success(self): """apply_refactor applies string replacement to a real file.""" tmp_dir = Path(tempfile.mkdtemp()) (tmp_dir / ".git").mkdir() target_file = tmp_dir / "example.py" target_file.write_text("def old_func():\n pass\n", encoding="utf-8") try: rid = "success1" with _refactor_lock: _pending_refactors[rid] = { "refactor_id": rid, "type": "rename", "old_name": "old_func", "new_name": "new_func", "edits": [{ "file": str(target_file), "line": 1, "old": "old_func", "new": "new_func", "confidence": "high", }], "stats": {"high": 1, "medium": 0, "low": 0}, "created_at": time.time(), } result = apply_refactor(rid, tmp_dir) assert result["status"] == "ok" assert result["edits_applied"] == 1 assert len(result["files_modified"]) == 1 # Verify file content was changed. content = target_file.read_text(encoding="utf-8") assert "new_func" in content assert "old_func" not in content finally: target_file.unlink(missing_ok=True) (tmp_dir / ".git").rmdir() tmp_dir.rmdir() def test_apply_refactor_dry_run_returns_diff_without_writing(self): """dry_run=True returns a unified diff without touching disk and keeps the refactor_id valid for a follow-up write (#176).""" tmp_dir = Path(tempfile.mkdtemp()) (tmp_dir / ".git").mkdir() target_file = tmp_dir / "example.py" original = "def old_func():\n pass\n" target_file.write_text(original, encoding="utf-8") try: rid = "dryrun1" with _refactor_lock: _pending_refactors[rid] = { "refactor_id": rid, "type": "rename", "old_name": "old_func", "new_name": "new_func", "edits": [{ "file": str(target_file), "line": 1, "old": "old_func", "new": "new_func", "confidence": "high", }], "stats": {"high": 1, "medium": 0, "low": 0}, "created_at": time.time(), } # Step 1: dry_run — no writes, returns diff result = apply_refactor(rid, tmp_dir, dry_run=True) assert result["status"] == "ok" assert result["dry_run"] is True assert result["edits_applied"] == 1 assert len(result["would_modify"]) == 1 assert result["files_modified"] == [] # nothing written yet assert str(target_file) in result["would_modify"] # Diff should mention both the old and new name diff = result["diffs"][str(target_file)] assert "-def old_func():" in diff assert "+def new_func():" in diff # File on disk must be unchanged assert target_file.read_text(encoding="utf-8") == original # Step 2: refactor_id should still be valid — dry_run doesn't consume it with _refactor_lock: assert rid in _pending_refactors # Step 3: real apply — uses same refactor_id real_result = apply_refactor(rid, tmp_dir, dry_run=False) assert real_result["status"] == "ok" assert real_result.get("dry_run") is None # not set on the real path assert real_result["edits_applied"] == 1 assert len(real_result["files_modified"]) == 1 # File content changed new_content = target_file.read_text(encoding="utf-8") assert "new_func" in new_content assert "old_func" not in new_content # refactor_id consumed after real apply with _refactor_lock: assert rid not in _pending_refactors finally: target_file.unlink(missing_ok=True) (tmp_dir / ".git").rmdir() tmp_dir.rmdir() def test_apply_refactor_dry_run_no_edits(self): """dry_run with an empty edit list returns an empty diff dict.""" tmp_dir = Path(tempfile.mkdtemp()) (tmp_dir / ".git").mkdir() try: rid = "dryrun-empty" with _refactor_lock: _pending_refactors[rid] = { "refactor_id": rid, "type": "rename", "old_name": "x", "new_name": "y", "edits": [], "stats": {"high": 0, "medium": 0, "low": 0}, "created_at": time.time(), } result = apply_refactor(rid, tmp_dir, dry_run=True) assert result["status"] == "ok" assert result["dry_run"] is True assert result["would_modify"] == [] assert result["diffs"] == {} finally: with _refactor_lock: _pending_refactors.pop("dryrun-empty", None) (tmp_dir / ".git").rmdir() tmp_dir.rmdir() class TestPendingRefactorsThreadSafe: """Tests for thread-safety of the pending refactors storage.""" def test_pending_refactors_thread_safe(self): """The _refactor_lock is a threading.Lock instance.""" assert isinstance(_refactor_lock, type(threading.Lock())) def test_concurrent_access(self): """Multiple threads can safely access _pending_refactors.""" results = [] def writer(rid: str): with _refactor_lock: _pending_refactors[rid] = { "refactor_id": rid, "created_at": time.time(), } results.append(rid) threads = [threading.Thread(target=writer, args=(f"t{i}",)) for i in range(10)] for t in threads: t.start() for t in threads: t.join() with _refactor_lock: assert len(results) == 10 assert len(_pending_refactors) >= 10 # Clean up _pending_refactors.clear() class TestFindDeadCodeWithReferences: """Tests for REFERENCES-aware dead code detection.""" def setup_method(self): self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) self.store = GraphStore(self.tmp.name) self._seed() def teardown_method(self): self.store.close() Path(self.tmp.name).unlink(missing_ok=True) def _seed(self): """Seed with functions that have REFERENCES edges (map dispatch pattern).""" # File self.store.upsert_node(NodeInfo( kind="File", name="/repo/handlers.ts", file_path="/repo/handlers.ts", line_start=1, line_end=100, language="typescript", )) # A function referenced in a map (should NOT be dead) self.store.upsert_node(NodeInfo( kind="Function", name="handleCreate", file_path="/repo/handlers.ts", line_start=10, line_end=20, language="typescript", )) # A function with CALLS edge (should NOT be dead) self.store.upsert_node(NodeInfo( kind="Function", name="calledFunc", file_path="/repo/handlers.ts", line_start=30, line_end=40, language="typescript", )) # A truly dead function (no edges at all) self.store.upsert_node(NodeInfo( kind="Function", name="deadFunc", file_path="/repo/handlers.ts", line_start=50, line_end=60, language="typescript", )) # Caller self.store.upsert_node(NodeInfo( kind="Function", name="dispatch", file_path="/repo/handlers.ts", line_start=70, line_end=80, language="typescript", )) # REFERENCES edge: dispatch -> handleCreate (map dispatch pattern) self.store.upsert_edge(EdgeInfo( kind="REFERENCES", source="/repo/handlers.ts::dispatch", target="/repo/handlers.ts::handleCreate", file_path="/repo/handlers.ts", line=75, )) # CALLS edge: dispatch -> calledFunc self.store.upsert_edge(EdgeInfo( kind="CALLS", source="/repo/handlers.ts::dispatch", target="/repo/handlers.ts::calledFunc", file_path="/repo/handlers.ts", line=76, )) self.store.commit() def test_referenced_function_not_dead(self): """Functions with REFERENCES edges should NOT be flagged as dead code.""" dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "handleCreate" not in dead_names def test_called_function_not_dead(self): """Functions with CALLS edges remain excluded (existing behavior).""" dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "calledFunc" not in dead_names def test_truly_dead_function_still_reported(self): """Functions with no edges at all should still be flagged as dead code.""" dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "deadFunc" in dead_names def test_only_references_edge_sufficient(self): """A function with ONLY a REFERENCES edge (no CALLS/IMPORTS) is not dead.""" dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} # handleCreate has only a REFERENCES edge, no CALLS targeting it assert "handleCreate" not in dead_names class TestTransitiveImportResolution: """Tests for 2-hop transitive import resolution in plausible caller.""" def setup_method(self): self.store = GraphStore(":memory:") for f in ("/repo/consumer.ts", "/repo/lib/index.ts", "/repo/lib/utils.ts"): self.store.upsert_node(NodeInfo( kind="File", name=f, file_path=f, line_start=1, line_end=50, language="typescript", )) def test_transitive_import_via_barrel_file(self): """consumer.ts imports index.ts which re-exports from utils.ts. A bare-name CALLS from consumer.ts should be plausible for utils.ts functions.""" # Function defined in utils.ts self.store.upsert_node(NodeInfo( kind="Function", name="safeJsonParse", file_path="/repo/lib/utils.ts", line_start=10, line_end=20, language="typescript", )) # Import chain: consumer -> index -> utils self.store.upsert_edge(EdgeInfo( kind="IMPORTS_FROM", source="/repo/consumer.ts", target="/repo/lib/index.ts", file_path="/repo/consumer.ts", line=1, )) self.store.upsert_edge(EdgeInfo( kind="IMPORTS_FROM", source="/repo/lib/index.ts", target="/repo/lib/utils.ts", file_path="/repo/lib/index.ts", line=1, )) # Bare-name CALLS from consumer self.store.upsert_edge(EdgeInfo( kind="CALLS", source="/repo/consumer.ts::processData", target="safeJsonParse", file_path="/repo/consumer.ts", line=5, )) self.store.commit() dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "safeJsonParse" not in dead_names, ( "2-hop import chain should make consumer a plausible caller" ) class TestFindDeadCodeModuleScope: """End-to-end regression: parse → store → find_dead_code. Pins the contract that functions invoked only from module scope are not flagged as dead. Bypasses the hand-built graph fixtures used elsewhere in this file so that a regression in any of the parser's 5 module-scope CALLS paths is caught. """ def setup_method(self): self.tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False) self.store = GraphStore(self.tmp.name) self.parser = CodeParser() def teardown_method(self): self.store.close() Path(self.tmp.name).unlink(missing_ok=True) def _store_parsed(self, path: Path, source: bytes) -> None: nodes, edges = self.parser.parse_bytes(path, source) for n in nodes: self.store.upsert_node(n) for e in edges: self.store.upsert_edge(e) self.store.commit() def test_module_scope_caller_prevents_dead_code_flag(self, tmp_path): """A function called only from top-level script glue is not dead.""" # ``run_job`` has no non-dunder name match and no framework decorator, # so without the module-scope CALLS fix it would be flagged dead. path = tmp_path / "script.py" path.write_bytes( b"def run_job():\n" b" return 1\n" b"\n" b"run_job()\n" ) self._store_parsed(path, path.read_bytes()) dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "run_job" not in dead_names, ( "module-scope caller should prevent run_job from being flagged dead" ) def test_if_main_block_caller_prevents_dead_code_flag(self, tmp_path): """A function called only inside ``if __name__ == '__main__'`` is not dead.""" path = tmp_path / "cli.py" path.write_bytes( b"def launch():\n" b" return 1\n" b"\n" b"if __name__ == '__main__':\n" b" launch()\n" ) self._store_parsed(path, path.read_bytes()) dead = find_dead_code(self.store) dead_names = {d["name"] for d in dead} assert "launch" not in dead_names