fold/tests/unit/test_project_index.cpp
trvon d67264ca3e
fix(call-graph): merge same-USR duplicate evidence like the exhaustive builder
Iteration 3 of the fold optimization loop
(docs/research/optimization-loop-2026-07-30.md).

mergeTranslationUnitGraphContributions hard-failed with 'conflicting
semantic-USR function evidence' when the same function USR recurred
across TU contributions with spelling-variant qualified names (libc++
inline-namespace std:: vs std::__1) or an empty body hash (TU-dependent
template instantiation). ProjectCallGraph::addFunction — the exhaustive
fallback builder this merge must mirror — merges duplicates leniently,
so the incremental path could never succeed on real C++ codebases and
every scan silently paid the exhaustive fallback.

Measured (fold self-scan, 8 TUs):
- call-graph phase with --cache-dir: 24.1s median -> 1.7s (93% cut);
  exhaustive-fallback warnings eliminated
- project eval wall median: 160.1s -> 92.6s (n=2, partly noise)

Finding fingerprints are exactly preserved (fold 0fec78e5..., yams
247523c5...; churn 0, pairwise Jaccard 1.0, both project evals ok).
Full suite 136/137 (sole failure: pre-existing benchmark_evaluation
fixture-schema issue, unrelated area). Red test added: 'contribution
merge tolerates same-USR spelling variants like the exhaustive
builder' (test_project_index.cpp).
2026-08-30 20:39:38 -06:00

618 lines
30 KiB
C++

#include <sqlite3.h>
#include <algorithm>
#include <catch2/catch_test_macros.hpp>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <string>
#include <vector>
#include "fold/cli/source_call_graph.hpp"
#include "fold/index/store.hpp"
namespace {
fold::index::TranslationUnitGraphContribution contribution(std::string source,
std::string command_hash = "command-v1",
std::string header_hash = "header-v1") {
return {
.fingerprint = {.source_path = source,
.compile_command_hash = command_hash,
.source_hash = "source-v1",
.toolchain_hash = "clang-v1",
.dependencies = {{source, "source-v1", fold::index::DependencyKind::Source},
{"shared.hpp", header_hash,
fold::index::DependencyKind::Header}}},
.functions = {{"usr:entry:" + source, "entry", source, "entry-v1", true, false, 1, {}, {}},
{"usr:shared", "shared", "shared.hpp", "body-v1", true, false, 0, {}, {}}},
.edges = {{"usr:entry:" + source, "usr:shared", fold::index::EdgeKind::Conservative, 0.75}},
.unresolved_calls = {{"callsite:" + source,
"usr:entry:" + source,
fold::index::UnresolvedCallKind::Virtual,
"void(Base::*)()",
"usr:base",
{"family:a", "family:b"}}},
.guarded_calls = {{"guarded:" + source, "usr:entry:" + source, "usr:shared",
fold::index::EdgeKind::Direct,
fold::ir::ConfigGuard::projected(
fold::ir::ConfigGuardExpr::boolSymbol(std::string(64, 'a')))}},
.production_roots = {{"usr:entry:" + source, "entry", source,
fold::analysis::ProductionRootKind::Main, source, 1, 1, true, false}},
};
}
std::filesystem::path temporaryDatabase() {
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
return std::filesystem::temp_directory_path() /
("fold-project-index-" + std::to_string(stamp) + ".sqlite");
}
void executeSql(const std::filesystem::path& path, const char* sql) {
sqlite3* database = nullptr;
REQUIRE(sqlite3_open(path.string().c_str(), &database) == SQLITE_OK);
char* error = nullptr;
const int status = sqlite3_exec(database, sql, nullptr, nullptr, &error);
INFO(std::string(error == nullptr ? "" : error));
CHECK(status == SQLITE_OK);
sqlite3_free(error);
sqlite3_close(database);
}
} // namespace
TEST_CASE("project index round-trips versioned TU contributions",
"[project-index][tu-contribution]") {
fold::index::ProjectIndex index(":memory:");
REQUIRE(index.isOpen());
const auto expected = contribution("a.cpp");
REQUIRE(index.replaceTranslationUnitContribution(expected));
REQUIRE(index.replaceTranslationUnitContribution(expected));
const auto loaded = index.loadTranslationUnitContributions();
REQUIRE(loaded.status == fold::index::ContributionLoadStatus::Ok);
REQUIRE(loaded.contributions.size() == 1);
CHECK(loaded.contributions.front() == expected);
}
TEST_CASE("project index TU replacement tracks source header and flag changes atomically",
"[project-index][tu-contribution][replace]") {
fold::index::ProjectIndex index(":memory:");
REQUIRE(index.isOpen());
REQUIRE(index.replaceTranslationUnitContribution(contribution("a.cpp")));
auto changed = contribution("a.cpp", "command-with-DENABLE", "header-v2");
changed.fingerprint.source_hash = "source-v2";
changed.functions.erase(changed.functions.begin());
changed.edges.clear();
changed.unresolved_calls.clear();
changed.production_roots.clear();
REQUIRE(index.replaceTranslationUnitContribution(changed));
auto loaded = index.loadTranslationUnitContributions();
REQUIRE(loaded.contributions.size() == 1);
CHECK(loaded.contributions.front() == changed);
auto invalid = changed;
invalid.functions.push_back(invalid.functions.front());
CHECK_FALSE(index.replaceTranslationUnitContribution(invalid));
loaded = index.loadTranslationUnitContributions();
REQUIRE(loaded.contributions.size() == 1);
CHECK(loaded.contributions.front() == changed);
}
TEST_CASE("project index preserves shared header ownership across TU removal",
"[project-index][tu-contribution][shared-header]") {
fold::index::ProjectIndex index(":memory:");
REQUIRE(index.replaceTranslationUnitContribution(contribution("a.cpp")));
auto second = contribution("b.cpp", "command-v2", "header-v2");
const auto shared =
std::find_if(second.functions.begin(), second.functions.end(),
[](const auto& function) { return function.usr == "usr:shared"; });
REQUIRE(shared != second.functions.end());
shared->body_hash = "body-v2";
REQUIRE(index.replaceTranslationUnitContribution(second));
auto loaded = index.loadTranslationUnitContributions();
REQUIRE(loaded.contributions.size() == 2);
for (const auto& item : loaded.contributions) {
CHECK(std::any_of(item.functions.begin(), item.functions.end(),
[](const auto& function) { return function.usr == "usr:shared"; }));
}
REQUIRE(index.removeTranslationUnitContribution("a.cpp"));
loaded = index.loadTranslationUnitContributions();
REQUIRE(loaded.contributions.size() == 1);
CHECK(loaded.contributions.front().fingerprint.source_path == "b.cpp");
const auto remaining_shared =
std::find_if(loaded.contributions.front().functions.begin(),
loaded.contributions.front().functions.end(),
[](const auto& function) { return function.usr == "usr:shared"; });
REQUIRE(remaining_shared != loaded.contributions.front().functions.end());
CHECK(remaining_shared->body_hash == "body-v2");
}
TEST_CASE("project index self-heals missing contribution schema metadata",
"[project-index][tu-contribution][schema]") {
const auto path = temporaryDatabase();
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.replaceTranslationUnitContribution(contribution("a.cpp")));
}
executeSql(path, "DELETE FROM graph_contribution_meta WHERE key='schema_version';");
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.isOpen());
// Stale graph tables are dropped and recreated at the current schema,
// so the index reopens writable instead of failing closed forever.
CHECK(index.replaceTranslationUnitContribution(contribution("b.cpp")));
CHECK(index.loadTranslationUnitContributions().status ==
fold::index::ContributionLoadStatus::Ok);
}
std::filesystem::remove(path);
}
TEST_CASE("project index self-heals future contribution schemas",
"[project-index][tu-contribution][schema]") {
const auto path = temporaryDatabase();
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.isOpen());
REQUIRE(index.replaceTranslationUnitContribution(contribution("a.cpp")));
}
executeSql(path, "UPDATE graph_contribution_meta SET value='999' "
"WHERE key='schema_version';");
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.isOpen());
// A future schema invalidates every stored contribution; the cache
// resets itself to the current schema instead of refusing all writes.
CHECK(index.replaceTranslationUnitContribution(contribution("b.cpp")));
const auto loaded = index.loadTranslationUnitContributions();
CHECK(loaded.status == fold::index::ContributionLoadStatus::Ok);
CHECK_FALSE(loaded.contributions.empty());
}
std::filesystem::remove(path);
}
TEST_CASE("project index rejects malformed contribution rows without partial loads",
"[project-index][tu-contribution][corrupt]") {
const auto path = temporaryDatabase();
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.replaceTranslationUnitContribution(contribution("a.cpp")));
}
executeSql(path, "UPDATE graph_edges SET kind=99;");
{
fold::index::ProjectIndex index(path.string());
const auto loaded = index.loadTranslationUnitContributions();
CHECK(loaded.status == fold::index::ContributionLoadStatus::Corrupt);
CHECK(loaded.contributions.empty());
}
std::filesystem::remove(path);
}
TEST_CASE("project index rejects malformed persisted production roots",
"[project-index][tu-contribution][roots][corrupt]") {
const auto path = temporaryDatabase();
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.replaceTranslationUnitContribution(contribution("a.cpp")));
}
executeSql(path, "UPDATE graph_production_roots SET production=2;");
{
fold::index::ProjectIndex index(path.string());
const auto loaded = index.loadTranslationUnitContributions();
CHECK(loaded.status == fold::index::ContributionLoadStatus::Corrupt);
CHECK(loaded.contributions.empty());
}
std::filesystem::remove(path);
}
TEST_CASE("project index rejects malformed persisted configuration guards",
"[project-index][tu-contribution][guard][corrupt]") {
const auto path = temporaryDatabase();
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.replaceTranslationUnitContribution(contribution("a.cpp")));
}
executeSql(path, "UPDATE graph_guarded_calls SET guard_json='{\"status\":\"projected\"}';");
{
fold::index::ProjectIndex index(path.string());
const auto loaded = index.loadTranslationUnitContributions();
CHECK(loaded.status == fold::index::ContributionLoadStatus::Corrupt);
CHECK(loaded.contributions.empty());
}
std::filesystem::remove(path);
}
TEST_CASE("project index persists USR nodes and typed uncertain edges", "[project-index]") {
const auto path = temporaryDatabase();
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.isOpen());
const auto stats = index.replaceTranslationUnit(
"a.cpp",
{{"usr:a", "a", "a.cpp", "hash-a", true, false, {}, {}, {}},
{"usr:b", "b", "a.cpp", "hash-b", true, true, 7, std::string{},
std::vector<std::string>{}}},
{{"usr:a", "usr:b", fold::index::EdgeKind::Direct, 1.0},
{"usr:b", "usr:external", fold::index::EdgeKind::Unknown, 0.0}});
CHECK(stats.changed_functions == 2);
CHECK(stats.reused_functions == 0);
}
{
fold::index::ProjectIndex reopened(path.string());
REQUIRE(reopened.isOpen());
REQUIRE(reopened.functions().size() == 2);
const auto edges = reopened.outgoingEdges("usr:b");
REQUIRE(edges.size() == 1);
CHECK(edges[0].kind == fold::index::EdgeKind::Unknown);
CHECK(edges[0].confidence == 0.0);
CHECK(reopened.functions()[1].escapes);
CHECK(reopened.functions()[1].scc_id == 7);
}
std::filesystem::remove(path);
}
TEST_CASE("project index round-trips unified graphs and reports warm reuse",
"[project-index][graph]") {
fold::index::ProjectIndex index(":memory:");
REQUIRE(index.isOpen());
fold::ir::ProjectCallGraph cold;
cold.addFunction("usr:caller", "caller", "caller.cpp", true, "body-caller");
cold.addFunction("usr:callee", "callee", "callee.cpp", true, "body-callee", true);
cold.addCallEdge("usr:caller", "usr:callee", fold::ir::CallGraphEdgeKind::Conservative, 0.5);
cold.computeSCCs();
const std::vector<std::pair<std::string, std::string>> inputs = {{"file:caller.cpp", "hash-1"},
{"file:callee.cpp", "hash-2"}};
const auto first = index.replaceProjectGraph(cold, inputs);
CHECK(first.changed_functions == 2);
CHECK(first.reused_functions == 0);
fold::ir::ProjectCallGraph loaded;
REQUIRE(index.loadProjectGraph(loaded));
REQUIRE(index.matchesInputFingerprints(inputs));
CHECK(loaded.nodeCount() == 2);
CHECK(loaded.edgeCount(fold::ir::CallGraphEdgeKind::Conservative) == 1);
CHECK(loaded.getNode("usr:callee")->escapes);
fold::ir::ProjectCallGraph reordered;
reordered.addFunction("usr:callee", "callee", "callee.cpp", true, "body-callee", true);
reordered.addFunction("usr:caller", "caller", "caller.cpp", true, "body-caller");
reordered.addCallEdge("usr:caller", "usr:callee", fold::ir::CallGraphEdgeKind::Conservative,
0.5);
reordered.computeSCCs();
const auto warm = index.replaceProjectGraph(reordered, inputs);
CHECK(warm.reused_functions == 2);
CHECK(warm.changed_functions == 0);
CHECK(warm.changed_usrs.empty());
fold::ir::ProjectCallGraph changed;
changed.addFunction("usr:caller", "caller", "caller.cpp", true, "body-caller");
changed.addFunction("usr:callee", "callee", "callee.cpp", true, "body-callee-v2", true);
changed.addCallEdge("usr:caller", "usr:callee", fold::ir::CallGraphEdgeKind::Conservative, 0.5);
changed.computeSCCs();
const auto update = index.replaceProjectGraph(changed);
CHECK(update.reused_functions == 1);
CHECK(update.changed_functions == 1);
CHECK(update.invalidated_dependents == 1);
CHECK(update.changed_usrs == std::vector<std::string>{"usr:callee"});
CHECK(update.invalidated_usrs == std::vector<std::string>{"usr:caller"});
fold::ir::ProjectCallGraph removed;
removed.addFunction("usr:caller", "caller", "caller.cpp", true, "body-caller");
removed.computeSCCs();
const auto removal = index.replaceProjectGraph(removed);
CHECK(removal.removed_functions == 1);
CHECK(index.functions().size() == 1);
}
TEST_CASE("project index corruption fails closed", "[project-index][corrupt]") {
const auto path = temporaryDatabase();
{
std::ofstream output(path, std::ios::binary);
output << "not a sqlite database";
}
fold::index::ProjectIndex index(path.string());
CHECK_FALSE(index.isOpen());
CHECK_FALSE(index.error().empty());
std::filesystem::remove(path);
}
TEST_CASE("project index replacement reports reuse and invalidates reverse dependents",
"[project-index][incremental]") {
fold::index::ProjectIndex index(":memory:");
REQUIRE(index.isOpen());
index.replaceTranslationUnit("a.cpp",
{{"usr:a", "a", "a.cpp", "hash-a", true, false, {}, {}, {}}},
{{"usr:a", "usr:b", fold::index::EdgeKind::Conservative, 0.5}});
index.replaceTranslationUnit("b.cpp",
{{"usr:b", "b", "b.cpp", "hash-b", true, false, {}, {}, {}}},
{{"usr:b", "usr:c", fold::index::EdgeKind::Unknown, 0.0}});
index.replaceTranslationUnit("c.cpp",
{{"usr:c", "c", "c.cpp", "hash-c", true, false, {}, {}, {}}}, {});
CHECK(index.reverseDependents({"usr:c"}) == std::vector<std::string>{"usr:a", "usr:b"});
const auto unchanged = index.replaceTranslationUnit(
"a.cpp", {{"usr:a", "a", "a.cpp", "hash-a", true, false, {}, {}, {}}},
{{"usr:a", "usr:b", fold::index::EdgeKind::Conservative, 0.5}});
CHECK(unchanged.reused_functions == 1);
CHECK(unchanged.changed_functions == 0);
const auto changed = index.replaceTranslationUnit(
"a.cpp",
{{"usr:a", "a", "a.cpp", "hash-a2", true, false, {}, {}, {}},
{"usr:new", "new_function", "a.cpp", "hash-new", true, false, {}, {}, {}}},
{});
CHECK(changed.changed_functions == 2);
CHECK(changed.reused_functions == 0);
CHECK(changed.removed_functions == 0);
CHECK(index.outgoingEdges("usr:a").empty());
}
TEST_CASE("project index chunks reverse-dependents past sqlite compound-select limit",
"[project-index][incremental][large]") {
// A fresh or large incremental scan changes more than 500 USRs; the
// recursive-CTE seed list then exceeds SQLite's 500-term compound SELECT
// cap (SQLITE_MAX_COMPOUND_SELECT) and the update must still succeed with
// the exact transitive dependents.
fold::index::ProjectIndex index(":memory:");
REQUIRE(index.isOpen());
// reverseDependents walks reverse edges (callers of changed functions), so
// build a fan-in closure: g_i -> f_i and h_i -> g_i. Changing the 600 f_*
// bodies seeds 600 USRs and must invalidate exactly {g_*, h_*}.
constexpr int kChanged = 600;
constexpr int kFunctions = 3 * kChanged;
const auto build_closure = [&](bool mutate_functions) {
fold::ir::ProjectCallGraph graph;
const auto usr = [](const char* prefix, int i) {
return std::string("usr:") + prefix + std::to_string(i);
};
const auto file_of = [](int i) { return i < kChanged ? "a.cpp" : "b.cpp"; };
for (int i = 0; i < kChanged; ++i) {
const std::string body =
mutate_functions ? "body2-" + std::to_string(i) : "body-" + std::to_string(i);
graph.addFunction(usr("f", i), "f" + std::to_string(i), file_of(i), true, body);
graph.addFunction(usr("g", i), "g" + std::to_string(i), file_of(i), true,
"body-g-" + std::to_string(i));
graph.addFunction(usr("h", i), "h" + std::to_string(i), file_of(i), true,
"body-h-" + std::to_string(i));
graph.addCallEdge(usr("g", i), usr("f", i), fold::ir::CallGraphEdgeKind::Direct, 1.0);
graph.addCallEdge(usr("h", i), usr("g", i), fold::ir::CallGraphEdgeKind::Direct, 1.0);
}
graph.computeSCCs();
return graph;
};
const std::vector<std::pair<std::string, std::string>> inputs = {{"file:a.cpp", "hash-1"},
{"file:b.cpp", "hash-2"}};
const auto cold = index.replaceProjectGraph(build_closure(false), inputs);
CHECK(cold.changed_functions == kFunctions);
CHECK(index.error().empty());
// Every function is a seed, so nothing can be a non-seed dependent.
CHECK(cold.invalidated_usrs.empty());
// 600 changed functions -> 600 CTE seed terms, previously > the 500-term
// compound-SELECT cap (prepare failed, error_ set, invalidated_usrs empty).
const auto update = index.replaceProjectGraph(build_closure(true));
CHECK(index.error().empty());
CHECK(update.changed_functions == kChanged);
CHECK(update.reused_functions == 2 * kChanged);
// Dependents of {f0..f599} = {g0..g599} U {h0..h599} (multi-hop closure).
REQUIRE(update.invalidated_usrs.size() == 2 * kChanged);
CHECK(update.invalidated_usrs.front() == "usr:g0");
CHECK(update.invalidated_usrs.back() == "usr:h99");
CHECK(std::is_sorted(update.invalidated_usrs.begin(), update.invalidated_usrs.end()));
CHECK(index.error().empty());
// Warm reuse round-trips identically and stays error-free.
const auto warm = index.replaceProjectGraph(build_closure(true), inputs);
CHECK(index.error().empty());
CHECK(warm.reused_functions == kFunctions);
CHECK(warm.changed_functions == 0);
CHECK(warm.invalidated_usrs.empty());
fold::ir::ProjectCallGraph loaded;
REQUIRE(index.loadProjectGraph(loaded));
CHECK(loaded.nodeCount() == kFunctions);
}
TEST_CASE("contribution merge resolves unresolved calls cross-TU like the exhaustive builder",
"[project-index][merge][resolution]") {
using fold::index::EdgeKind;
using fold::index::TranslationUnitGraphContribution;
using fold::index::UnresolvedCallKind;
const auto base = [](std::string usr, std::string name) {
return fold::index::FunctionRecord{usr,
name,
"a.cpp",
"body-" + usr,
true,
false,
0,
"void (int)",
std::vector<std::string>{}};
};
const std::string family_key = "Base::run|void (int)";
TranslationUnitGraphContribution a;
a.fingerprint = {.source_path = "a.cpp",
.compile_command_hash = "cc-a",
.source_hash = "sh-a",
.toolchain_hash = "tc",
.configuration_profile_hash = fold::index::kNoConfigurationProfileHash,
.schema_version = fold::index::kGraphContributionSchemaVersion,
.dependencies = {{"a.cpp", "sh-a", fold::index::DependencyKind::Source}}};
auto caller_record = base("usr:caller", "caller");
caller_record.canonical_signature = "void (void)";
a.functions.push_back(caller_record);
a.unresolved_calls.push_back({.callsite_id = "a.cpp:1:1",
.caller_usr = "usr:caller",
.kind = UnresolvedCallKind::Virtual,
.canonical_signature = {},
.direct_target_usr = "usr:Base::run",
.override_family_keys = {family_key}});
a.unresolved_calls.push_back({.callsite_id = "a.cpp:2:1",
.caller_usr = "usr:caller",
.kind = UnresolvedCallKind::Indirect,
.canonical_signature = "void (int)",
.direct_target_usr = {},
.override_family_keys = {}});
TranslationUnitGraphContribution b;
b.fingerprint = {.source_path = "b.cpp",
.compile_command_hash = "cc-b",
.source_hash = "sh-b",
.toolchain_hash = "tc",
.configuration_profile_hash = fold::index::kNoConfigurationProfileHash,
.schema_version = fold::index::kGraphContributionSchemaVersion,
.dependencies = {{"b.cpp", "sh-b", fold::index::DependencyKind::Source}}};
auto base_run = base("usr:Base::run", "Base::run");
base_run.override_family_keys = {family_key};
auto derived_run = base("usr:Derived::run", "Derived::run");
derived_run.override_family_keys = {family_key};
auto handler = base("usr:handler", "handler");
handler.canonical_signature = "void (int)";
b.functions = {base_run, derived_run, handler};
// Strict merge (the non-reachability path that previously forced the
// exhaustive fallback): resolution matches both family targets and the
// signature target; nothing is refused.
fold::ir::ProjectCallGraph graph;
std::string reason;
REQUIRE(fold::cli::mergeTranslationUnitGraphContributions({a, b}, graph, reason,
/*allow_unresolved_calls=*/false));
CHECK(reason.empty());
CHECK(graph.edgeCount(fold::ir::CallGraphEdgeKind::Conservative) == 3);
CHECK(graph.edgeCount(fold::ir::CallGraphEdgeKind::Unknown) == 0);
const auto callees = graph.getCalleeIds("usr:caller");
REQUIRE(callees.size() == 3);
CHECK(std::find(callees.begin(), callees.end(), "usr:Base::run") != callees.end());
CHECK(std::find(callees.begin(), callees.end(), "usr:Derived::run") != callees.end());
CHECK(std::find(callees.begin(), callees.end(), "usr:handler") != callees.end());
// A genuinely unresolvable indirect call degrades to an Unknown synthetic
// edge, exactly like the exhaustive builder.
TranslationUnitGraphContribution lone;
lone.fingerprint = {.source_path = "c.cpp",
.compile_command_hash = "cc-c",
.source_hash = "sh-c",
.toolchain_hash = "tc",
.configuration_profile_hash = fold::index::kNoConfigurationProfileHash,
.schema_version = fold::index::kGraphContributionSchemaVersion,
.dependencies = {{"c.cpp", "sh-c", fold::index::DependencyKind::Source}}};
lone.functions.push_back(base("usr:lone", "lone"));
lone.unresolved_calls.push_back({.callsite_id = "c.cpp:1:1",
.caller_usr = "usr:lone",
.kind = UnresolvedCallKind::Indirect,
.canonical_signature = "int (void)",
.direct_target_usr = {},
.override_family_keys = {}});
fold::ir::ProjectCallGraph partial;
REQUIRE(fold::cli::mergeTranslationUnitGraphContributions({lone}, partial, reason,
/*allow_unresolved_calls=*/false));
CHECK(partial.edgeCount(fold::ir::CallGraphEdgeKind::Unknown) == 1);
CHECK(partial.unresolvedNodeCount() == 1);
// allow_unresolved_calls=true (reachability-only path) keeps dropping
// unresolved evidence without synthetic nodes.
fold::ir::ProjectCallGraph loose;
REQUIRE(fold::cli::mergeTranslationUnitGraphContributions({lone}, loose, reason,
/*allow_unresolved_calls=*/true));
CHECK(loose.edgeCount(fold::ir::CallGraphEdgeKind::Unknown) == 0);
CHECK(loose.edgeCount() == 0);
}
TEST_CASE("contribution merge tolerates same-USR spelling variants like the exhaustive builder",
"[project-index][merge][usr-conflict]") {
using fold::index::TranslationUnitGraphContribution;
// Real-world case (fold optimization loop iteration 3): libc++ inline
// template/inline functions appear in multiple TU contributions with the
// same semantic USR but spelling-variant qualified names (std:: vs
// std::__1) and sometimes an empty body hash (TU-dependent instantiation).
// ProjectCallGraph::addFunction — the exhaustive builder this merge must
// mirror — merges those duplicates; the merge must not refuse evidence
// the fallback tolerates.
const auto make_contribution = [](const std::string& path, const std::string& qualified_name,
const std::string& body_hash) {
TranslationUnitGraphContribution c;
c.fingerprint = {
.source_path = path,
.compile_command_hash = "cc-" + path,
.source_hash = "sh-" + path,
.toolchain_hash = "tc",
.configuration_profile_hash = fold::index::kNoConfigurationProfileHash,
.schema_version = fold::index::kGraphContributionSchemaVersion,
.dependencies = {{path, "sh-" + path, fold::index::DependencyKind::Source}}};
c.functions.push_back({"usr:libcpp-fn",
qualified_name,
"/sdk/usr/include/c++/v1/header.h",
body_hash,
true,
false,
0,
{},
{}});
c.functions.push_back(
{"usr:caller", "caller", path, "body-caller", true, false, 0, {}, {}});
return c;
};
auto a = make_contribution("a.cpp", "std::__1::operator<<", "7249926610571155715");
auto b = make_contribution("b.cpp", "std::operator<<", "7249926610571155715");
auto c = make_contribution("c.cpp", "std::operator<<", "");
fold::ir::ProjectCallGraph graph;
std::string reason;
REQUIRE(fold::cli::mergeTranslationUnitGraphContributions({a, b, c}, graph, reason,
/*allow_unresolved_calls=*/false));
CHECK(reason.empty());
const auto* merged = graph.getNode("usr:libcpp-fn");
REQUIRE(merged != nullptr);
CHECK(merged->is_defined);
CHECK(!merged->body_hash.empty());
}
TEST_CASE("contribution store round-trips cross-TU resolution facts",
"[project-index][tu-contribution][resolution]") {
const auto path = temporaryDatabase();
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.isOpen());
fold::index::TranslationUnitGraphContribution contribution;
contribution.fingerprint = {.source_path = "a.cpp",
.compile_command_hash = "cc-a",
.source_hash = "sh-a",
.toolchain_hash = "tc",
.configuration_profile_hash =
fold::index::kNoConfigurationProfileHash,
.schema_version = fold::index::kGraphContributionSchemaVersion,
.dependencies = {}};
contribution.functions.push_back({"usr:Base::run",
"Base::run",
"a.cpp",
"body",
true,
false,
0,
"void (int)",
{"Base::run|void (int)", "Base::run|void (long)"}});
REQUIRE(index.replaceTranslationUnitContribution(contribution));
}
{
fold::index::ProjectIndex index(path.string());
REQUIRE(index.isOpen());
const auto loaded = index.loadTranslationUnitContributions();
REQUIRE(loaded.status == fold::index::ContributionLoadStatus::Ok);
REQUIRE(loaded.contributions.size() == 1);
REQUIRE(loaded.contributions[0].functions.size() == 1);
const auto& function = loaded.contributions[0].functions[0];
CHECK(function.canonical_signature == "void (int)");
CHECK(function.override_family_keys ==
std::vector<std::string>{"Base::run|void (int)", "Base::run|void (long)"});
}
std::filesystem::remove(path);
}