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
6 changes: 5 additions & 1 deletion REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -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`
Expand Down
21 changes: 21 additions & 0 deletions app/services/graph_traversal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions tests/test_trace_path_di_callers.py
Original file line number Diff line number Diff line change
@@ -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 `<field>.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")
Loading