diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index b8c7a34..a5828a9 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **1176 Automated Tests** (812 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). +**Test Verification Baseline:** **1178 Automated Tests** (814 Pytest Backend + 314 Vitest Frontend + 50 Playwright E2E). --- @@ -1393,6 +1393,10 @@ over the PostgresConnectionAdapter using an initialized test engine._ - `test_include_permalinks_returns_them_on_every_node` - `test_compact_answer_drops_the_four_node_permalinks` +#### `tests/test_trace_path_di_callers.py` (2 tests) +- `test_same_named_methods_get_only_their_injected_callers` +- `test_undecidable_calls_are_kept` + #### `tests/test_utf16_file_reads.py` (4 tests) - `test_text_samples_are_not_binary` - `test_real_binary_sample_is_binary` diff --git a/app/services/graph_traversal.py b/app/services/graph_traversal.py index 0c7ee05..11ca18b 100644 --- a/app/services/graph_traversal.py +++ b/app/services/graph_traversal.py @@ -220,6 +220,25 @@ def _resolve_di_callee(raw_target: str, caller_sym: str, candidates: List[dict]) return matched[0].get("full_symbol") or matched[0].get("name") or raw_target return raw_target + def _call_reaches(r_target: str, caller_sym: str, curr_sym: str) -> bool: + """ + Call targets are stored as bare method names (`_toolService.PublishInstance(...)` -> `PublishInstance`). + For a qualified target, resolve the call from the caller's class with the same DI rules as callees: + when it resolves to a different concrete method, the row is not a caller of `curr_sym`. + Calls from the target's own class, and undecidable calls, are kept. + """ + if "." not in curr_sym or not r_target or "." in r_target: + return True + curr_cls = curr_sym.rsplit(".", 1)[0] + caller_cls = caller_sym.rsplit(".", 1)[0] if "." in caller_sym else caller_sym + if caller_cls == curr_cls or caller_cls.endswith(f".{curr_cls}"): + return True + candidates = _resolve_symbol_candidates(r_target) + if len(candidates) < 2: + return True + resolved = _resolve_di_callee(r_target, caller_sym, candidates) + return resolved == r_target or resolved == curr_sym or resolved.endswith(f".{curr_sym}") + def _resolve_enclosing_symbol(r_repo: str, r_filepath: str, line_num: Optional[int], fallback_sym: str, source_symbol_id: Optional[int] = None) -> Tuple[str, Optional[dict]]: if source_symbol_id and source_symbol_id in sym_by_id: s_obj = sym_by_id[source_symbol_id] @@ -361,6 +380,8 @@ def _make_edge_node(display_sym: str, row_obj: object, child_key: str) -> dict: caller_sym, caller_obj = _resolve_enclosing_symbol(r["repo"], r["source_filepath"], r["line_number"], r["source_symbol"], r["source_symbol_id"]) if (caller_obj and caller_obj.get("id") in curr_ids and "." in curr_sym) or caller_sym == curr_sym: continue + if not _call_reaches(r_target, caller_sym, curr_sym): + continue edge_key = (caller_sym, curr_sym, r["relationship_type"], "caller", r["line_number"]) if edge_key in visited_edges: continue diff --git a/tests/test_trace_path_di_callers.py b/tests/test_trace_path_di_callers.py new file mode 100644 index 0000000..a4ccf61 --- /dev/null +++ b/tests/test_trace_path_di_callers.py @@ -0,0 +1,103 @@ +"""trace_path callers must not mix up same-named methods of different classes (controller -> service -> repository).""" + +import json + +import pytest + +from app.services.database.engine import get_db_engine, init_db +from app.services.database import get_db_connection +from app.services.graph_traversal import trace_symbol_path + +REPO = "hub" +# class -> (file, constructor signature with the injected interface) +CLASSES = { + "AssistantsController": ("Controllers/AssistantsController.cs", "AssistantsController(IAssistantService assistantService)"), + "ToolsController": ("Controllers/ToolsController.cs", "ToolsController(IToolService toolService)"), + "AssistantService": ("Services/AssistantService.cs", "AssistantService(IAssistantRepository assistantRepository)"), + "ToolService": ("Services/ToolService.cs", "ToolService(IToolRepository toolRepository)"), + "AssistantRepository": ("Data/AssistantRepository.cs", "AssistantRepository(IDbConnection connection)"), + "ToolRepository": ("Data/ToolRepository.cs", "ToolRepository(IDbConnection connection)"), +} +IMPLEMENTS = { + "AssistantService": "IAssistantService", "ToolService": "IToolService", + "AssistantRepository": "IAssistantRepository", "ToolRepository": "IToolRepository", +} +# Each class's PublishInstance calls `.PublishInstance(...)`, stored as the bare name. +CALLERS = ["AssistantsController", "ToolsController", "AssistantService", "ToolService"] + + +@pytest.fixture +def di_graph(tmp_path, monkeypatch): + db_file = tmp_path / "di_trace.db" + db_url = f"sqlite:///{db_file}" + monkeypatch.setenv("DATABASE_URL", db_url) + monkeypatch.setattr("app.services.database.connection.CACHE_DB_PATH", str(db_file)) + monkeypatch.setattr("app.services.database.CACHE_DB_PATH", str(db_file), raising=False) + init_db(engine=get_db_engine(db_url, reset=True)) + + method_ids = {} + with get_db_connection() as conn: + def add_symbol(fp, name, full, kind, start, end, sig): + cur = conn.execute( + "INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'c_sharp')", + (REPO, f"{REPO}://{fp}", name, full, kind, start, end, sig), + ) + return cur.lastrowid + + for cls, (fp, ctor_sig) in CLASSES.items(): + add_symbol(fp, cls, cls, "class_declaration", 1, 100, f"public class {cls}") + add_symbol(fp, cls, f"{cls}.{cls}", "constructor_declaration", 5, 9, ctor_sig) + method_ids[cls] = add_symbol(fp, "PublishInstance", f"{cls}.PublishInstance", "method_declaration", 20, 40, "PublishInstance(Guid id)") + for iface in set(IMPLEMENTS.values()): + add_symbol(f"Interfaces/{iface}.cs", iface, iface, "interface_declaration", 1, 10, f"public interface {iface}") + add_symbol(f"Interfaces/{iface}.cs", "PublishInstance", f"{iface}.PublishInstance", "method_declaration", 3, 3, "PublishInstance(Guid id)") + for cls, iface in IMPLEMENTS.items(): + conn.execute( + "INSERT INTO ast_relationships (repo, source_filepath, source_symbol, target_symbol, relationship_type, line_number) " + "VALUES (?, ?, ?, ?, 'IMPLEMENTS', 1)", + (REPO, f"{REPO}://{CLASSES[cls][0]}", cls, iface), + ) + for cls in CALLERS: + conn.execute( + "INSERT INTO ast_relationships (repo, source_symbol_id, source_filepath, source_symbol, target_symbol, relationship_type, line_number) " + "VALUES (?, ?, ?, 'PublishInstance', 'PublishInstance', 'CALLS', 30)", + (REPO, method_ids[cls], f"{REPO}://{CLASSES[cls][0]}"), + ) + conn.commit() + return method_ids + + +def _callers(symbol: str): + result = json.loads(trace_symbol_path(symbol=symbol, repo=REPO, direction="callers", depth=1, limit=50)) + return sorted(node["symbol"] for node in result["callers"]) + + +@pytest.mark.parametrize( + "target, expected", + [ + ("AssistantService.PublishInstance", ["AssistantsController.PublishInstance"]), + ("ToolService.PublishInstance", ["ToolsController.PublishInstance"]), + ("AssistantRepository.PublishInstance", ["AssistantService.PublishInstance"]), + ("ToolRepository.PublishInstance", ["ToolService.PublishInstance"]), + ], +) +def test_same_named_methods_get_only_their_injected_callers(di_graph, target, expected): + assert _callers(target) == expected + + +def test_undecidable_calls_are_kept(di_graph): + # A class with no injected interface for PublishInstance cannot be resolved: keep the edge (no false negatives). + with get_db_connection() as conn: + cur = conn.execute( + "INSERT INTO ast_symbols (repo, filepath, name, full_symbol, kind, start_line, end_line, signature, language) " + "VALUES (?, 'hub://Jobs/Nightly.cs', 'Run', 'NightlyJob.Run', 'method_declaration', 10, 20, 'Run()', 'c_sharp')", + (REPO,), + ) + conn.execute( + "INSERT INTO ast_relationships (repo, source_symbol_id, source_filepath, source_symbol, target_symbol, relationship_type, line_number) " + "VALUES (?, ?, 'hub://Jobs/Nightly.cs', 'Run', 'PublishInstance', 'CALLS', 15)", + (REPO, cur.lastrowid), + ) + conn.commit() + assert "NightlyJob.Run" in _callers("AssistantService.PublishInstance")