// Copyright (c) 2025 YAMS Contributors // SPDX-License-Identifier: GPL-3.0-or-later #include #include #include #include #include #include #include "../../../common/metadata_test_db.h" #include #include #include #include #include #include #include using namespace yams::app::services; using namespace yams::metadata; namespace { struct GraphContextServiceFixture { GraphContextServiceFixture() { dbPath = yams::test::migrated_metadata_db_template().clone("yams_graph_context_service_db_"); testDir = dbPath.parent_path() / dbPath.stem(); std::filesystem::create_directories(testDir); ConnectionPoolConfig poolConfig; poolConfig.minConnections = 1; poolConfig.maxConnections = 2; pool = std::make_shared(dbPath.string(), poolConfig); auto poolInitRes = pool->initialize(); REQUIRE((poolInitRes.has_value())); metadataRepo = std::make_shared( *pool, nullptr, MetadataRepository::SchemaBootstrapMode::AssumeReady); KnowledgeGraphStoreConfig kgConfig; kgConfig.enable_alias_fts = true; kgConfig.enable_wal = false; auto kgResult = makeSqliteKnowledgeGraphStore(*pool, kgConfig); REQUIRE((kgResult.has_value())); kgStore = std::shared_ptr(std::move(kgResult).value()); metadataRepo->setKnowledgeGraphStore(kgStore); } ~GraphContextServiceFixture() { kgStore.reset(); metadataRepo.reset(); pool.reset(); std::error_code ec; std::filesystem::remove_all(testDir, ec); yams::test::remove_sqlite_artifacts(dbPath); } std::filesystem::path writeSource(const std::string& relativePath, const std::vector& lines) { auto path = testDir / relativePath; std::filesystem::create_directories(path.parent_path()); std::ofstream out(path); REQUIRE((out.good())); for (const auto& line : lines) { out << line << '\n'; } return path; } SymbolMetadata symbol(const std::filesystem::path& path, std::string name, std::string qualifiedName, std::int32_t startLine, std::int32_t endLine, std::string kind = "function") { SymbolMetadata sym; sym.documentHash = "hash-" + qualifiedName; sym.filePath = path.string(); sym.symbolName = std::move(name); sym.qualifiedName = std::move(qualifiedName); sym.kind = std::move(kind); sym.startLine = startLine; sym.endLine = endLine; return sym; } void insertDocumentFor(const SymbolMetadata& sym) { DocumentInfo doc; doc.filePath = sym.filePath; const auto derived = computePathDerivedValues(doc.filePath); doc.fileName = std::filesystem::path(derived.normalizedPath).filename().string(); doc.fileExtension = std::filesystem::path(derived.normalizedPath).extension().string(); doc.fileSize = std::filesystem::exists(sym.filePath) ? static_cast(std::filesystem::file_size(sym.filePath)) : 0; doc.sha256Hash = sym.documentHash; doc.mimeType = "text/plain"; doc.pathPrefix = derived.pathPrefix; doc.reversePath = derived.reversePath; doc.pathHash = derived.pathHash; doc.parentHash = derived.parentHash; doc.pathDepth = derived.pathDepth; const auto now = std::chrono::floor(std::chrono::system_clock::now()); doc.createdTime = now; doc.modifiedTime = now; doc.indexedTime = now; auto inserted = metadataRepo->insertDocument(doc); REQUIRE((inserted.has_value())); } void upsertSymbols(const std::vector& symbols) { for (const auto& sym : symbols) { insertDocumentFor(sym); } REQUIRE((kgStore->upsertSymbolMetadata(symbols).has_value())); } std::int64_t upsertNodeFor(const SymbolMetadata& sym) { KGNode node; node.nodeKey = sym.kind + ":" + sym.qualifiedName + "@" + sym.filePath; node.label = sym.symbolName; node.type = sym.kind; auto nodeId = kgStore->upsertNode(node); REQUIRE((nodeId.has_value())); return nodeId.value(); } std::filesystem::path testDir; std::filesystem::path dbPath; std::shared_ptr pool; std::shared_ptr metadataRepo; std::shared_ptr kgStore; }; } // namespace TEST_CASE("GraphContextService explore ranks source symbols before tests by default", "[services][graph][context]") { GraphContextServiceFixture fixture; auto sourcePath = fixture.writeSource("src/process.cpp", { "namespace demo {", "int helper() { return 1; }", "int processTask() {", " return helper();", "}", "}", }); auto testPath = fixture.writeSource("tests/process_task_test.cpp", { "#include ", "TEST_CASE(\"processTask\") {", " REQUIRE(processTask() == 1);", "}", }); auto sourceSym = fixture.symbol(sourcePath, "processTask", "demo::processTask", 3, 5); auto testSym = fixture.symbol(testPath, "processTask", "demo::processTaskTest", 2, 4); fixture.upsertSymbols({sourceSym, testSym}); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "processTask"; req.budget.maxFiles = 4; req.budget.maxSymbols = 4; auto result = service->explore(req); REQUIRE((result.has_value())); const auto& response = result.value(); REQUIRE((response.entrySymbols.size() == 1)); CHECK((response.entrySymbols.front().filePath == sourcePath.string())); REQUIRE((response.files.size() == 1)); CHECK((response.files.front().filePath == sourcePath.string())); CHECK((response.files.front().content.find("3\tint processTask()") != std::string::npos)); CHECK((response.files.front().content.find("5\t}") != std::string::npos)); } TEST_CASE("GraphContextService explore applies repository scope before symbol budgets", "[services][graph][context][scope]") { GraphContextServiceFixture fixture; const auto localPath = fixture.writeSource("repo-z/src/request_handler.cpp", {"void RequestHandler() {}"}); const auto localSymbol = fixture.symbol(localPath, "RequestHandler", "local::RequestHandler", 1, 1); std::vector indexedSymbols; indexedSymbols.push_back(localSymbol); for (std::size_t index = 0; index < 20; ++index) { const auto foreignPath = fixture.writeSource("repo-z-copy/copy_" + std::to_string(index) + "/src/request_handler.cpp", {"void RequestHandler() {}"}); indexedSymbols.push_back(fixture.symbol(foreignPath, "RequestHandler", "foreign::RequestHandler" + std::to_string(index), 1, 1)); } const auto foreignSymbol = indexedSymbols[1]; fixture.upsertSymbols(indexedSymbols); const auto localTarget = fixture.symbol(localPath, "dispatch", "local::dispatch", 1, 1); const auto upsertVersionNode = [&](const SymbolMetadata& symbol, std::string_view snapshot) { KGNode node; node.nodeKey = symbol.kind + ":" + symbol.qualifiedName + "@" + symbol.filePath + "@snap:" + std::string(snapshot); node.label = symbol.symbolName; node.type = symbol.kind + "_version"; auto nodeId = fixture.kgStore->upsertNode(node); REQUIRE((nodeId.has_value())); return nodeId.value(); }; const auto localId = fixture.upsertNodeFor(localSymbol); const auto localTargetId = upsertVersionNode(localTarget, "local-target"); const auto foreignId = fixture.upsertNodeFor(foreignSymbol); REQUIRE((fixture.kgStore ->addEdge(KGEdge{.srcNodeId = localId, .dstNodeId = localTargetId, .relation = "calls", .weight = 1.0F}) .has_value())); REQUIRE( (fixture.kgStore ->addEdge(KGEdge{ .srcNodeId = localId, .dstNodeId = foreignId, .relation = "calls", .weight = 1.0F}) .has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "request_handler.cpp"; req.scopePathPrefix = localPath.parent_path().parent_path().string(); req.budget.maxFiles = 1; req.budget.maxSymbols = 1; auto result = service->explore(req); REQUIRE((result.has_value())); REQUIRE((result.value().entrySymbols.size() == 1)); CHECK((result.value().entrySymbols.front().qualifiedName == "local::RequestHandler")); REQUIRE((result.value().files.size() == 1)); CHECK((result.value().files.front().filePath == localPath.string())); REQUIRE((result.value().relationships.size() == 1)); CHECK((result.value().relationships.front().targetLabel == "dispatch")); } TEST_CASE("GraphContextService explore resolves C++ qualified symbol queries", "[services][graph][context]") { GraphContextServiceFixture fixture; auto sourcePath = fixture.writeSource("src/vector/sqlite_vec_backend.cpp", { "namespace yams::vector {", "class SqliteVecBackend {", " struct Impl {", " void bruteForceSearch();", " };", "};", "}", }); const std::string qualified = "yams::vector::SqliteVecBackend::Impl::bruteForceSearch"; auto sym = fixture.symbol(sourcePath, "bruteForceSearch", qualified, 4, 4); fixture.upsertSymbols({sym}); // Mirror production alias extraction (EntityGraphService): simple name, fully // qualified name, and partial qualified suffixes. const auto nodeId = fixture.upsertNodeFor(sym); const std::vector aliases = { "bruteForceSearch", qualified, "vector::SqliteVecBackend::Impl::bruteForceSearch", "SqliteVecBackend::Impl::bruteForceSearch", "Impl::bruteForceSearch", }; for (const auto& text : aliases) { KGAlias alias; alias.nodeId = nodeId; alias.alias = text; alias.source = std::string("test"); alias.confidence = 1.0f; REQUIRE((fixture.kgStore->addAlias(alias).has_value())); } auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); const auto explore = [&](const std::string& query) { GraphExploreRequest req; req.query = query; req.budget.maxFiles = 4; req.budget.maxSymbols = 4; return service->explore(req); }; const auto findsSymbol = [](const GraphExploreResponse& response) { for (const auto& entry : response.entrySymbols) { if (entry.qualifiedName == "yams::vector::SqliteVecBackend::Impl::bruteForceSearch") { return true; } } return false; }; SECTION("Shortened qualifier resolves instead of failing with an SQL error") { auto result = explore("SqliteVecBackend::bruteForceSearch"); REQUIRE((result.has_value())); CHECK((findsSymbol(result.value()))); } SECTION("Fully indexed qualified name resolves") { auto result = explore("yams::vector::SqliteVecBackend::Impl::bruteForceSearch"); REQUIRE((result.has_value())); CHECK((findsSymbol(result.value()))); } SECTION("Unqualified simple name resolves") { auto result = explore("bruteForceSearch"); REQUIRE((result.has_value())); CHECK((findsSymbol(result.value()))); } SECTION("Nonexistent qualified symbol misses without an SQL error") { auto result = explore("Missing::doesNotExist"); REQUIRE((result.has_value())); CHECK(result.value().entrySymbols.empty()); } } TEST_CASE("GraphContextService explore can include tests when requested", "[services][graph][context]") { GraphContextServiceFixture fixture; auto sourcePath = fixture.writeSource("src/process.cpp", {"int processTask() {", "return 1;", "}"}); auto testPath = fixture.writeSource("tests/process_task_test.cpp", {"TEST_CASE(\"processTask\") {", "processTask();", "}"}); auto sourceSym = fixture.symbol(sourcePath, "processTask", "demo::processTask", 1, 3); auto testSym = fixture.symbol(testPath, "processTask", "demo::processTaskTest", 1, 3); fixture.upsertSymbols({sourceSym, testSym}); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "processTask"; req.includeTests = true; req.budget.maxFiles = 4; req.budget.maxSymbols = 4; auto result = service->explore(req); REQUIRE((result.has_value())); const auto& response = result.value(); REQUIRE((response.entrySymbols.size() == 2)); CHECK((response.totalFilesConsidered == 2)); REQUIRE((response.files.size() == 2)); CHECK((response.files[0].filePath == sourcePath.string())); CHECK((response.files[1].filePath == testPath.string())); } TEST_CASE("GraphContextService explore applies snippet budgets and omits code when requested", "[services][graph][context]") { GraphContextServiceFixture fixture; auto sourcePath = fixture.writeSource("src/budget.cpp", { "int budgetedSymbol() {", " int value = 1;", " value += 2;", " value += 3;", " return value;", "}", }); auto sourceSym = fixture.symbol(sourcePath, "budgetedSymbol", "demo::budgetedSymbol", 1, 6); fixture.upsertSymbols({sourceSym}); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest limitedReq; limitedReq.query = "budgetedSymbol"; limitedReq.budget.maxSnippetLines = 2; limitedReq.budget.maxCharsPerFile = 10; limitedReq.budget.maxTotalChars = 10; auto limited = service->explore(limitedReq); REQUIRE((limited.has_value())); REQUIRE((limited.value().files.size() == 1)); CHECK(limited.value().truncated); CHECK(limited.value().files.front().truncated); CHECK((limited.value().files.front().content.size() <= 10)); CHECK((limited.value().snippetsRendered == 1)); CHECK((limited.value().snippetRenderMicros >= 0)); GraphExploreRequest omittedReq; omittedReq.query = "budgetedSymbol"; omittedReq.includeCode = false; auto omitted = service->explore(omittedReq); REQUIRE((omitted.has_value())); REQUIRE((omitted.value().files.size() == 1)); CHECK((omitted.value().files.front().mode == GraphContextSnippetMode::Omitted)); CHECK(omitted.value().files.front().content.empty()); CHECK((omitted.value().snippetsRendered == 0)); CHECK((omitted.value().snippetRenderMicros == 0)); } TEST_CASE("GraphContextService explore clamps zero-based symbol lines", "[services][graph][context]") { GraphContextServiceFixture fixture; auto sourcePath = fixture.writeSource("src/zero_lines.cpp", {"int zeroBased() {", " return 42;", "}"}); auto zeroLineSym = fixture.symbol(sourcePath, "zeroBased", "demo::zeroBased", 0, 0); fixture.upsertSymbols({zeroLineSym}); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "zeroBased"; auto result = service->explore(req); REQUIRE((result.has_value())); REQUIRE((result.value().files.size() == 1)); CHECK((result.value().files.front().startLine == 1)); CHECK((result.value().files.front().endLine == 3)); CHECK((result.value().files.front().content.find("1\tint zeroBased() {") != std::string::npos)); } TEST_CASE("GraphContextService explore tolerates stale symbol lines past end of file", "[services][graph][context]") { GraphContextServiceFixture fixture; auto sourcePath = fixture.writeSource("src/stale_lines.cpp", {"int staleLocation() {", " return 42;", "}"}); auto staleLineSym = fixture.symbol(sourcePath, "staleLocation", "demo::staleLocation", 100, 104); fixture.upsertSymbols({staleLineSym}); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "staleLocation"; auto result = service->explore(req); REQUIRE((result.has_value())); REQUIRE((result.value().files.size() == 1)); CHECK(result.value().truncated); CHECK((result.value().files.front().mode == GraphContextSnippetMode::Omitted)); CHECK(result.value().files.front().truncated); CHECK(result.value().files.front().content.empty()); REQUIRE((result.value().warnings.size() == 1)); CHECK((result.value().warnings.front().find("stale source location") != std::string::npos)); } TEST_CASE("GraphContextService explore supports file path queries", "[services][graph][context]") { GraphContextServiceFixture fixture; auto sourcePath = fixture.writeSource("src/path_query.cpp", {"int firstSymbol() {", " return 1;", "}", "int secondSymbol() {", " return 2;", "}"}); auto firstSym = fixture.symbol(sourcePath, "firstSymbol", "demo::firstSymbol", 1, 3); auto secondSym = fixture.symbol(sourcePath, "secondSymbol", "demo::secondSymbol", 4, 6); fixture.upsertSymbols({firstSym, secondSym}); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = sourcePath.string(); req.budget.maxFiles = 2; req.budget.maxSymbols = 4; auto result = service->explore(req); REQUIRE((result.has_value())); const auto& response = result.value(); REQUIRE((response.entrySymbols.size() == 2)); CHECK((response.entrySymbols.front().filePath == sourcePath.string())); CHECK((response.entrySymbols.back().filePath == sourcePath.string())); REQUIRE((response.files.size() == 1)); CHECK((response.files.front().filePath == sourcePath.string())); CHECK((response.files.front().content.find("1\tint firstSymbol() {") != std::string::npos)); CHECK((response.files.front().content.find("4\tint secondSymbol() {") != std::string::npos)); } TEST_CASE("GraphContextService explore favors multi-term file coverage", "[services][graph][context][natural-language]") { GraphContextServiceFixture fixture; auto searchDistractorPath = fixture.writeSource("src/a_search.cpp", {"int Search() { return 1; }"}); auto hintsDistractorPath = fixture.writeSource("src/b_hints.cpp", {"struct SSL_HANDSHAKE_HINTS {};"}); auto relevantPath = fixture.writeSource("src/z_graph_context.cpp", {"int searchGraphContext() { return 2; }", "int renderGraphHints() { return 3; }"}); auto searchDistractor = fixture.symbol(searchDistractorPath, "Search", "external::Search", 1, 1); auto hintsDistractor = fixture.symbol(hintsDistractorPath, "SSL_HANDSHAKE_HINTS", "external::SSL_HANDSHAKE_HINTS", 1, 1, "struct"); auto searchGraphContext = fixture.symbol(relevantPath, "searchGraphContext", "yams::searchGraphContext", 1, 1); auto renderGraphHints = fixture.symbol(relevantPath, "renderGraphHints", "yams::renderGraphHints", 2, 2); fixture.upsertSymbols( {searchDistractor, hintsDistractor, searchGraphContext, renderGraphHints}); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "how search graph context hints work"; req.budget.maxFiles = 2; req.budget.maxSymbols = 2; auto result = service->explore(req); REQUIRE((result.has_value())); const auto& response = result.value(); REQUIRE_FALSE(response.files.empty()); CHECK((response.files.front().filePath == relevantPath.string())); REQUIRE((response.entrySymbols.size() == 2)); CHECK((response.entrySymbols.front().filePath == relevantPath.string())); CHECK((response.entrySymbols.back().filePath == relevantPath.string())); } TEST_CASE("GraphContextService explore relocates stale in-bounds symbol lines", "[services][graph][context][hydration]") { GraphContextServiceFixture fixture; auto sourcePath = fixture.writeSource("src/graph_context.cpp", {"int unrelated() { return 1; }", "int affectedTests() { return 2; }"}); auto staleSymbol = fixture.symbol(sourcePath, "affectedTests", "demo::affectedTests", 1, 1); fixture.upsertSymbols({staleSymbol}); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "affectedTests"; req.budget.maxSnippetLines = 1; auto result = service->explore(req); REQUIRE((result.has_value())); REQUIRE((result.value().files.size() == 1)); const auto& snippet = result.value().files.front(); CHECK((snippet.startLine == 2)); CHECK((snippet.endLine == 2)); CHECK((snippet.content.find("affectedTests") != std::string::npos)); } TEST_CASE("GraphContextService explore falls back to graph nodes without symbol metadata", "[services][graph][context]") { GraphContextServiceFixture fixture; auto sourcePath = fixture.writeSource("src/fallback.cpp", {"int fallbackEntry() {", " return 7;", "}"}); DocumentInfo doc; doc.filePath = sourcePath.string(); const auto derived = computePathDerivedValues(doc.filePath); doc.fileName = sourcePath.filename().string(); doc.fileExtension = sourcePath.extension().string(); doc.fileSize = static_cast(std::filesystem::file_size(sourcePath)); doc.sha256Hash = "hash-fallback"; doc.mimeType = "text/plain"; doc.pathPrefix = derived.pathPrefix; doc.reversePath = derived.reversePath; doc.pathHash = derived.pathHash; doc.parentHash = derived.parentHash; doc.pathDepth = derived.pathDepth; const auto now = std::chrono::floor(std::chrono::system_clock::now()); doc.createdTime = now; doc.modifiedTime = now; doc.indexedTime = now; REQUIRE((fixture.metadataRepo->insertDocument(doc).has_value())); KGNode node; node.nodeKey = "function:demo::fallbackEntry@" + sourcePath.string(); node.label = "fallbackEntry"; node.type = "function"; REQUIRE((fixture.kgStore->upsertNode(node).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "fallbackEntry"; auto result = service->explore(req); REQUIRE((result.has_value())); REQUIRE((result.value().entrySymbols.size() == 1)); CHECK((result.value().entrySymbols.front().label == "fallbackEntry")); CHECK((result.value().entrySymbols.front().filePath == sourcePath.string())); REQUIRE((result.value().files.size() == 1)); CHECK((result.value().files.front().filePath == sourcePath.string())); CHECK((result.value().files.front().content.find("1\tint fallbackEntry() {") != std::string::npos)); CHECK_FALSE(result.value().warnings.empty()); CHECK((result.value().warnings.front().find("falling back to graph node labels") != std::string::npos)); } TEST_CASE("GraphContextService explore returns canonical relationship context", "[services][graph][context]") { GraphContextServiceFixture fixture; auto callerPath = fixture.writeSource("src/caller.cpp", {"int caller() {", "return callee();", "}"}); auto calleePath = fixture.writeSource("src/callee.cpp", {"int callee() {", "return 1;", "}"}); auto callerSym = fixture.symbol(callerPath, "caller", "demo::caller", 1, 3); auto calleeSym = fixture.symbol(calleePath, "callee", "demo::callee", 1, 3); fixture.upsertSymbols({callerSym, calleeSym}); const auto callerId = fixture.upsertNodeFor(callerSym); const auto calleeId = fixture.upsertNodeFor(calleeSym); KGEdge edge; edge.srcNodeId = callerId; edge.dstNodeId = calleeId; edge.relation = "call"; edge.weight = 0.75F; REQUIRE((fixture.kgStore->addEdge(edge).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "caller"; auto result = service->explore(req); REQUIRE((result.has_value())); const auto& response = result.value(); REQUIRE((response.relationships.size() == 1)); CHECK((response.relationships.front().relation == "calls")); CHECK((response.relationships.front().sourceLabel == "caller")); CHECK((response.relationships.front().targetLabel == "callee")); } TEST_CASE("GraphContextService explore relationship budget is not capped by symbol budget", "[services][graph][context]") { GraphContextServiceFixture fixture; auto rootPath = fixture.writeSource("src/root.cpp", {"int root() {", " return left() + right();", "}"}); auto leftPath = fixture.writeSource("src/left.cpp", {"int left() {", " return 1;", "}"}); auto rightPath = fixture.writeSource("src/right.cpp", {"int right() {", " return 2;", "}"}); auto rootSym = fixture.symbol(rootPath, "root", "demo::root", 1, 3); auto leftSym = fixture.symbol(leftPath, "left", "demo::left", 1, 3); auto rightSym = fixture.symbol(rightPath, "right", "demo::right", 1, 3); fixture.upsertSymbols({rootSym, leftSym, rightSym}); const auto rootId = fixture.upsertNodeFor(rootSym); const auto leftId = fixture.upsertNodeFor(leftSym); const auto rightId = fixture.upsertNodeFor(rightSym); KGEdge leftEdge; leftEdge.srcNodeId = rootId; leftEdge.dstNodeId = leftId; leftEdge.relation = "call"; leftEdge.weight = 0.8F; REQUIRE((fixture.kgStore->addEdge(leftEdge).has_value())); KGEdge rightEdge; rightEdge.srcNodeId = rootId; rightEdge.dstNodeId = rightId; rightEdge.relation = "call"; rightEdge.weight = 0.7F; REQUIRE((fixture.kgStore->addEdge(rightEdge).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphExploreRequest req; req.query = "root"; req.budget.maxFiles = 1; req.budget.maxSymbols = 1; auto result = service->explore(req); REQUIRE((result.has_value())); const auto& response = result.value(); REQUIRE((response.entrySymbols.size() == 1)); REQUIRE((response.relationships.size() == 2)); CHECK((response.relationships[0].sourceLabel == "root")); CHECK((response.relationships[0].targetLabel == "left")); CHECK((response.relationships[1].sourceLabel == "root")); CHECK((response.relationships[1].targetLabel == "right")); CHECK_FALSE(response.truncated); } TEST_CASE("GraphContextService lookupSymbol disambiguates by file", "[services][graph][context]") { GraphContextServiceFixture fixture; auto pathA = fixture.writeSource("src/a.cpp", {"int parseToken() { return 1; }"}); auto pathB = fixture.writeSource("src/b.cpp", {"int parseToken() { return 2; }"}); auto symA = fixture.symbol(pathA, "parseToken", "a::parseToken", 1, 1); auto symB = fixture.symbol(pathB, "parseToken", "b::parseToken", 1, 1); fixture.upsertSymbols({symA, symB}); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphSymbolLookupRequest ambiguousReq; ambiguousReq.symbol = "parseToken"; auto ambiguous = service->lookupSymbol(ambiguousReq); REQUIRE((ambiguous.has_value())); CHECK((ambiguous.value().matches.size() == 2)); CHECK(ambiguous.value().ambiguous); GraphSymbolLookupRequest scopedReq; scopedReq.symbol = "parseToken"; scopedReq.file = pathA.string(); auto scoped = service->lookupSymbol(scopedReq); REQUIRE((scoped.has_value())); REQUIRE((scoped.value().matches.size() == 1)); CHECK((scoped.value().matches.front().filePath == pathA.string())); CHECK_FALSE(scoped.value().ambiguous); } TEST_CASE("GraphContextService impact finds reverse dependents", "[services][graph][context]") { GraphContextServiceFixture fixture; auto callerPath = fixture.writeSource("src/caller.cpp", {"int caller() { return callee(); }"}); auto calleePath = fixture.writeSource("src/callee.cpp", {"int callee() { return 1; }"}); auto callerSym = fixture.symbol(callerPath, "caller", "demo::caller", 1, 1); auto calleeSym = fixture.symbol(calleePath, "callee", "demo::callee", 1, 1); fixture.upsertSymbols({callerSym, calleeSym}); const auto callerId = fixture.upsertNodeFor(callerSym); const auto calleeId = fixture.upsertNodeFor(calleeSym); KGEdge edge; edge.srcNodeId = callerId; edge.dstNodeId = calleeId; edge.relation = "calls"; edge.weight = 0.9F; REQUIRE((fixture.kgStore->addEdge(edge).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphImpactRequest req; req.symbol = "callee"; auto result = service->impact(req); REQUIRE((result.has_value())); REQUIRE((result.value().affectedSymbols.size() == 1)); CHECK((result.value().affectedSymbols.front().label == "caller")); CHECK_FALSE(result.value().relationships.empty()); } TEST_CASE("GraphContextService impact scopes seeds and traversal before result budgets", "[services][graph][context][scope]") { GraphContextServiceFixture fixture; const auto localCalleePath = fixture.writeSource("repo/src/callee.cpp", {"int callee() { return 1; }"}); const auto localCallerPath = fixture.writeSource("repo/src/caller.cpp", {"int caller() { return callee(); }"}); const auto foreignCalleePath = fixture.writeSource("other/src/callee.cpp", {"int callee() { return 2; }"}); const auto foreignCallerPath = fixture.writeSource("other/src/caller.cpp", {"int caller() { return callee(); }"}); const auto localCallee = fixture.symbol(localCalleePath, "callee", "local::callee", 1, 1); const auto localCaller = fixture.symbol(localCallerPath, "caller", "local::caller", 1, 1); const auto foreignCallee = fixture.symbol(foreignCalleePath, "callee", "foreign::callee", 1, 1); const auto foreignCaller = fixture.symbol(foreignCallerPath, "caller", "foreign::caller", 1, 1); fixture.upsertSymbols({localCallee, localCaller, foreignCallee, foreignCaller}); const auto localCalleeId = fixture.upsertNodeFor(localCallee); const auto localCallerId = fixture.upsertNodeFor(localCaller); const auto foreignCalleeId = fixture.upsertNodeFor(foreignCallee); const auto foreignCallerId = fixture.upsertNodeFor(foreignCaller); KGEdge localEdge; localEdge.srcNodeId = localCallerId; localEdge.dstNodeId = localCalleeId; localEdge.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(localEdge).has_value())); KGEdge foreignEdge; foreignEdge.srcNodeId = foreignCallerId; foreignEdge.dstNodeId = foreignCalleeId; foreignEdge.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(foreignEdge).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphImpactRequest req; req.symbol = "callee"; req.scopePathPrefix = localCalleePath.parent_path().parent_path().string(); req.budget.maxSymbols = 1; auto result = service->impact(req); REQUIRE((result.has_value())); REQUIRE((result.value().affectedSymbols.size() == 1)); CHECK((result.value().affectedSymbols.front().filePath == localCallerPath.string())); REQUIRE((result.value().relationships.size() == 1)); CHECK((result.value().relationships.front().sourceNodeKey.find(localCallerPath.string()) != std::string::npos)); } TEST_CASE("GraphContextService impact finds callers through symbol_reference placeholders", "[services][graph][context]") { // Mirrors the real KG topology: cross-file callers emit // caller_version --calls--> symbol_reference(callee) // rather than pointing at the callee's canonical definition node. GraphContextServiceFixture fixture; auto calleePath = fixture.writeSource("src/callee.cpp", {"int callee() { return 1; }"}); auto callerPath = fixture.writeSource("src/caller.cpp", {"int caller() { return callee(); }"}); auto calleeSym = fixture.symbol(calleePath, "callee", "demo::callee", 1, 1); fixture.upsertSymbols({calleeSym}); fixture.upsertNodeFor(calleeSym); // canonical function node for the callee KGNode placeholder; placeholder.nodeKey = "symbol_ref:demo::callee"; placeholder.label = "demo::callee"; placeholder.type = "symbol_reference"; auto placeholderId = fixture.kgStore->upsertNode(placeholder); REQUIRE((placeholderId.has_value())); KGNode callerVersion; callerVersion.nodeKey = "function:demo::caller@" + callerPath.string() + "@snap:abc"; callerVersion.label = "caller"; callerVersion.type = "function_version"; callerVersion.properties = nlohmann::json{{"qualified_name", "demo::caller"}, {"file_path", callerPath.string()}, {"start_line", 1}, {"end_line", 1}} .dump(); auto callerId = fixture.kgStore->upsertNode(callerVersion); REQUIRE((callerId.has_value())); KGEdge edge; edge.srcNodeId = callerId.value(); edge.dstNodeId = placeholderId.value(); edge.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(edge).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphImpactRequest req; req.symbol = "callee"; auto result = service->impact(req); REQUIRE((result.has_value())); REQUIRE((result.value().affectedSymbols.size() == 1)); CHECK((result.value().affectedSymbols.front().label == "caller")); CHECK((result.value().affectedSymbols.front().filePath == callerPath.string())); } TEST_CASE("GraphContextService impact follows resolves_to reconciliation links", "[services][graph][context]") { // A reconciliation pass links a placeholder (whose surface form does NOT match the symbol // name) to the canonical definition via `resolves_to`. impact must still find the caller // that points at that placeholder. GraphContextServiceFixture fixture; auto defPath = fixture.writeSource("src/def.cpp", {"int target() { return 1; }"}); auto defSym = fixture.symbol(defPath, "target", "demo::target", 1, 1); fixture.upsertSymbols({defSym}); const auto defId = fixture.upsertNodeFor(defSym); KGNode placeholder; placeholder.nodeKey = "symbol_ref:aliased::target"; placeholder.label = "aliasedSurfaceForm"; // intentionally does not match "target" placeholder.type = "symbol_reference"; const auto refId = fixture.kgStore->upsertNode(placeholder).value(); KGEdge resolvesTo; resolvesTo.srcNodeId = refId; resolvesTo.dstNodeId = defId; resolvesTo.relation = "resolves_to"; REQUIRE((fixture.kgStore->addEdge(resolvesTo).has_value())); auto callerPath = fixture.writeSource("src/caller.cpp", {"int caller() { return target(); }"}); auto callerSym = fixture.symbol(callerPath, "caller", "demo::caller", 1, 1); fixture.upsertSymbols({callerSym}); const auto callerId = fixture.upsertNodeFor(callerSym); KGEdge calls; calls.srcNodeId = callerId; calls.dstNodeId = refId; calls.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(calls).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphImpactRequest req; req.symbol = "target"; auto result = service->impact(req); REQUIRE((result.has_value())); REQUIRE((result.value().affectedSymbols.size() == 1)); CHECK((result.value().affectedSymbols.front().label == "caller")); } TEST_CASE("GraphContextService impact bridges placeholders across multiple hops", "[services][graph][context]") { // A_version --calls--> symbol_ref:B ; B_version --calls--> symbol_ref:C // impact(C) must reach B at depth 1 and A at depth 2 by bridging each discovered caller // version node to its own placeholder. GraphContextServiceFixture fixture; auto cPath = fixture.writeSource("src/c.cpp", {"int cFn() { return 1; }"}); auto cSym = fixture.symbol(cPath, "cFn", "demo::cFn", 1, 1); fixture.upsertSymbols({cSym}); fixture.upsertNodeFor(cSym); const auto makeRef = [&](const std::string& key, const std::string& label) { KGNode node; node.nodeKey = key; node.label = label; node.type = "symbol_reference"; auto id = fixture.kgStore->upsertNode(node); REQUIRE((id.has_value())); return id.value(); }; const auto makeVersion = [&](const std::string& simple, const std::string& qualified, const std::filesystem::path& path) { KGNode node; node.nodeKey = "function:" + qualified + "@" + path.string() + "@snap:" + simple; node.label = simple; node.type = "function_version"; node.properties = nlohmann::json{{"qualified_name", qualified}, {"file_path", path.string()}, {"start_line", 1}, {"end_line", 1}} .dump(); auto id = fixture.kgStore->upsertNode(node); REQUIRE((id.has_value())); return id.value(); }; auto refC = makeRef("symbol_ref:demo::cfn", "demo::cFn"); auto refB = makeRef("symbol_ref:demo::bfn", "demo::bFn"); auto bPath = fixture.writeSource("src/b.cpp", {"int bFn() { return cFn(); }"}); auto aPath = fixture.writeSource("src/a.cpp", {"int aFn() { return bFn(); }"}); auto bVersion = makeVersion("bFn", "demo::bFn", bPath); auto aVersion = makeVersion("aFn", "demo::aFn", aPath); KGEdge bCallsC; bCallsC.srcNodeId = bVersion; bCallsC.dstNodeId = refC; bCallsC.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(bCallsC).has_value())); KGEdge aCallsB; aCallsB.srcNodeId = aVersion; aCallsB.dstNodeId = refB; aCallsB.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(aCallsB).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphImpactRequest depth1; depth1.symbol = "cFn"; depth1.depth = 1; auto r1 = service->impact(depth1); REQUIRE((r1.has_value())); REQUIRE((r1.value().affectedSymbols.size() == 1)); CHECK((r1.value().affectedSymbols.front().label == "bFn")); GraphImpactRequest depth2; depth2.symbol = "cFn"; depth2.depth = 2; auto r2 = service->impact(depth2); REQUIRE((r2.has_value())); REQUIRE((r2.value().affectedSymbols.size() == 2)); std::vector labels; for (const auto& s : r2.value().affectedSymbols) labels.push_back(s.label); CHECK((std::find(labels.begin(), labels.end(), "aFn") != labels.end())); CHECK((std::find(labels.begin(), labels.end(), "bFn") != labels.end())); } TEST_CASE("GraphContextService trace finds a path across a call chain", "[services][graph][context]") { GraphContextServiceFixture fixture; auto aPath = fixture.writeSource("src/alpha.cpp", {"int alpha() { return beta(); }"}); auto bPath = fixture.writeSource("src/beta.cpp", {"int beta() { return gamma(); }"}); auto cPath = fixture.writeSource("src/gamma.cpp", {"int gamma() { return 1; }"}); auto aSym = fixture.symbol(aPath, "alpha", "demo::alpha", 1, 1); auto bSym = fixture.symbol(bPath, "beta", "demo::beta", 1, 1); auto cSym = fixture.symbol(cPath, "gamma", "demo::gamma", 1, 1); fixture.upsertSymbols({aSym, bSym, cSym}); const auto aId = fixture.upsertNodeFor(aSym); const auto bId = fixture.upsertNodeFor(bSym); const auto cId = fixture.upsertNodeFor(cSym); KGEdge ab; ab.srcNodeId = aId; ab.dstNodeId = bId; ab.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(ab).has_value())); KGEdge bc; bc.srcNodeId = bId; bc.dstNodeId = cId; bc.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(bc).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphTraceRequest req; req.from = "alpha"; req.to = "gamma"; auto result = service->trace(req); REQUIRE((result.has_value())); CHECK(result.value().found); REQUIRE((result.value().path.size() == 2)); GraphTraceRequest missingReq; missingReq.from = "gamma"; missingReq.to = "alpha"; missingReq.maxDepth = 1; auto missing = service->trace(missingReq); REQUIRE((missing.has_value())); CHECK_FALSE(missing.value().found); } TEST_CASE("GraphContextService affectedTests maps changed files to tests", "[services][graph][context]") { GraphContextServiceFixture fixture; auto srcPath = fixture.writeSource("src/widget.cpp", {"int widget() { return 1; }"}); auto testPath = fixture.writeSource("tests/widget_test.cpp", {"void t() { widget(); }"}); auto srcSym = fixture.symbol(srcPath, "widget", "demo::widget", 1, 1); auto testSym = fixture.symbol(testPath, "t", "demo::t", 1, 1); fixture.upsertSymbols({srcSym, testSym}); const auto srcId = fixture.upsertNodeFor(srcSym); const auto testId = fixture.upsertNodeFor(testSym); KGEdge edge; edge.srcNodeId = testId; edge.dstNodeId = srcId; edge.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(edge).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphAffectedTestsRequest req; req.changedFiles = {srcPath.string()}; auto result = service->affectedTests(req); REQUIRE((result.has_value())); REQUIRE((result.value().affectedTests.size() == 1)); CHECK((result.value().affectedTests.front() == testPath.string())); } TEST_CASE("GraphContextService affectedTests bounds changed-file symbol seeds", "[services][graph][context]") { GraphContextServiceFixture fixture; auto srcPath = fixture.writeSource("src/widget.cpp", {"int first() { return 1; }", "int second() { return 2; }"}); auto testPath = fixture.writeSource("tests/widget_test.cpp", {"void t() { second(); }"}); auto firstSym = fixture.symbol(srcPath, "first", "demo::first", 1, 1); auto secondSym = fixture.symbol(srcPath, "second", "demo::second", 2, 2); auto testSym = fixture.symbol(testPath, "t", "demo::t", 1, 1); fixture.upsertSymbols({firstSym, secondSym, testSym}); fixture.upsertNodeFor(firstSym); const auto secondId = fixture.upsertNodeFor(secondSym); const auto testId = fixture.upsertNodeFor(testSym); KGEdge edge; edge.srcNodeId = testId; edge.dstNodeId = secondId; edge.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(edge).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphAffectedTestsRequest req; req.changedFiles = {srcPath.string()}; req.budget.maxSymbols = 1; auto result = service->affectedTests(req); REQUIRE((result.has_value())); CHECK(result.value().affectedTests.empty()); CHECK(result.value().truncated); } TEST_CASE("GraphContextService affectedTests follows reconciled call placeholders", "[services][graph][context]") { GraphContextServiceFixture fixture; auto srcPath = fixture.writeSource("src/widget.cpp", {"int widget() { return 1; }"}); auto testPath = fixture.writeSource("tests/widget_test.cpp", {"void t() { widget(); }"}); auto srcSym = fixture.symbol(srcPath, "widget", "demo::widget", 1, 1); auto testSym = fixture.symbol(testPath, "t", "demo::t", 1, 1); fixture.upsertSymbols({srcSym, testSym}); const auto srcId = fixture.upsertNodeFor(srcSym); const auto testId = fixture.upsertNodeFor(testSym); KGNode placeholder; placeholder.nodeKey = "symbol_ref:aliased::widget"; placeholder.label = "unrelatedSurfaceForm"; placeholder.type = "symbol_reference"; const auto placeholderId = fixture.kgStore->upsertNode(placeholder).value(); KGEdge resolvesTo; resolvesTo.srcNodeId = placeholderId; resolvesTo.dstNodeId = srcId; resolvesTo.relation = "resolves_to"; REQUIRE((fixture.kgStore->addEdge(resolvesTo).has_value())); KGEdge calls; calls.srcNodeId = testId; calls.dstNodeId = placeholderId; calls.relation = "calls"; REQUIRE((fixture.kgStore->addEdge(calls).has_value())); auto service = makeGraphContextService(fixture.kgStore, fixture.metadataRepo); REQUIRE((service != nullptr)); GraphAffectedTestsRequest req; req.changedFiles = {srcPath.string()}; auto result = service->affectedTests(req); REQUIRE((result.has_value())); REQUIRE((result.value().affectedTests.size() == 1)); CHECK((result.value().affectedTests.front() == testPath.string())); }