1110 lines
47 KiB
C++
1110 lines
47 KiB
C++
// Copyright (c) 2025 YAMS Contributors
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <yams/app/services/graph_context_service.hpp>
|
|
#include <yams/metadata/connection_pool.h>
|
|
#include <yams/metadata/knowledge_graph_store.h>
|
|
#include <yams/metadata/metadata_repository.h>
|
|
#include <yams/metadata/path_utils.h>
|
|
|
|
#include "../../../common/metadata_test_db.h"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <chrono>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
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<ConnectionPool>(dbPath.string(), poolConfig);
|
|
auto poolInitRes = pool->initialize();
|
|
REQUIRE((poolInitRes.has_value()));
|
|
|
|
metadataRepo = std::make_shared<MetadataRepository>(
|
|
*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<KnowledgeGraphStore>(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<std::string>& 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::int64_t>(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::seconds>(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<SymbolMetadata>& 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<ConnectionPool> pool;
|
|
std::shared_ptr<MetadataRepository> metadataRepo;
|
|
std::shared_ptr<KnowledgeGraphStore> 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 <catch2/catch_test_macros.hpp>",
|
|
"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<SymbolMetadata> 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<std::string> 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::int64_t>(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::seconds>(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<std::string> 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()));
|
|
}
|