fold/tests/integration/test_incremental_project_graph.cpp
trvon fd4a68dfff
test: add coverage for project graph, pcode encoder, and detector behaviors
Unit + integration coverage for the session's work: incremental project
graph/index, pcode encoder, unreachable/dead-branch, equiv/cross-TU,
div-by-zero/sign-conversion/int-truncation, use-after-free, quality-precision,
scan-router profile, source TU runner, detector plan, and the eval fixtures.
2026-08-16 16:46:07 -06:00

419 lines
19 KiB
C++

#include <clang/Tooling/CompilationDatabase.h>
#include <algorithm>
#include <catch2/catch_test_macros.hpp>
#include <chrono>
#include <filesystem>
#include <fstream>
#include <nlohmann/json.hpp>
#include <string>
#include <tuple>
#include <vector>
#include "fold/cli/project_graph_orchestrator.hpp"
#include "fold/cli/source_call_graph.hpp"
#include "fold/config/configuration_profile.hpp"
#include "fold/frontend/comp_db.hpp"
#include "fold/index/store.hpp"
#include "fold/ir/call_graph.hpp"
namespace {
struct TempDir {
std::filesystem::path path;
TempDir() {
const auto stamp = std::chrono::steady_clock::now().time_since_epoch().count();
path = std::filesystem::temp_directory_path() /
("fold-incremental-project-graph-" + std::to_string(stamp));
std::filesystem::create_directories(path);
}
~TempDir() { std::filesystem::remove_all(path); }
};
void writeFile(const std::filesystem::path& path, const std::string& content) {
std::ofstream output(path);
REQUIRE(output.good());
output << content;
}
void writeCompileDb(const std::filesystem::path& directory,
const std::vector<std::filesystem::path>& files,
const std::string& extra_flags = {},
const std::filesystem::path& command_directory = {}) {
nlohmann::json commands = nlohmann::json::array();
for (const auto& file : files) {
nlohmann::json arguments = {"clang++", "-std=c++20"};
if (!extra_flags.empty())
arguments.push_back(extra_flags);
arguments.push_back("-c");
arguments.push_back(file.string());
commands.push_back(
{{"directory", (command_directory.empty() ? directory : command_directory).string()},
{"file", file.string()},
{"arguments", arguments}});
}
writeFile(directory / "compile_commands.json", commands.dump(2));
}
std::unique_ptr<clang::tooling::CompilationDatabase> loadDb(const std::filesystem::path& path) {
std::string error;
auto database = fold::loadCompilationDatabase(path.string(), error);
INFO(error);
REQUIRE(database);
return database;
}
struct GraphSnapshot {
std::vector<std::tuple<std::string, std::string, std::string, std::string, unsigned, bool, bool,
std::vector<std::string>, std::vector<std::string>>>
nodes;
std::vector<std::tuple<std::string, std::string, int, double>> edges;
bool operator==(const GraphSnapshot&) const = default;
};
GraphSnapshot snapshot(const fold::ir::ProjectCallGraph& graph) {
GraphSnapshot result;
for (const auto& node : graph.nodes()) {
result.nodes.emplace_back(
node.id, node.qualified_name, node.source_file, node.body_hash, node.scc_id,
node.is_defined, node.escapes,
std::vector<std::string>(node.callees.begin(), node.callees.end()),
std::vector<std::string>(node.callers.begin(), node.callers.end()));
}
for (const auto& edge : graph.edges()) {
result.edges.emplace_back(edge.caller_id, edge.callee_id, static_cast<int>(edge.kind),
edge.confidence);
}
return result;
}
fold::ir::ProjectCallGraph exhaustive(clang::tooling::CompilationDatabase& database,
const std::vector<std::string>& files) {
fold::ir::ProjectCallGraph graph;
fold::cli::buildProjectCallGraph(database, files, graph);
return graph;
}
void checkMatchesExhaustive(clang::tooling::CompilationDatabase& database,
const std::vector<std::string>& files,
const fold::ir::ProjectCallGraph& incremental) {
const auto expected = exhaustive(database, files);
CHECK(snapshot(incremental) == snapshot(expected));
}
std::size_t countNamed(const fold::ir::ProjectCallGraph& graph, const std::string& name) {
const auto nodes = graph.nodes();
return static_cast<std::size_t>(
std::count_if(nodes.begin(), nodes.end(),
[&name](const auto& node) { return node.qualified_name == name; }));
}
std::size_t countNamePrefix(const fold::ir::ProjectCallGraph& graph, const std::string& prefix) {
const auto nodes = graph.nodes();
return static_cast<std::size_t>(
std::count_if(nodes.begin(), nodes.end(), [&prefix](const auto& node) {
return node.qualified_name.starts_with(prefix);
}));
}
} // namespace
TEST_CASE("warm project graph reuses unchanged TU contributions without parsing",
"[project-graph][incremental][warm]") {
TempDir directory;
const auto header = directory.path / "shared.hpp";
const auto caller = directory.path / "caller.cpp";
const auto callee = directory.path / "callee.cpp";
writeFile(header, "static int header_local(){ return 3; }\n");
writeFile(caller, "#include \"shared.hpp\"\n"
"int cross_tu(int); int overloaded(int); double overloaded(double);\n"
"int entry(){ return cross_tu(1) + overloaded(1) + header_local(); }\n");
writeFile(callee, "#include \"shared.hpp\"\n"
"int cross_tu(int x){ return x + header_local(); }\n"
"int overloaded(int x){ return x; }\n"
"double overloaded(double x){ return x; }\n");
writeCompileDb(directory.path, {caller, callee});
auto database = loadDb(directory.path);
const std::vector<std::string> files{caller.string(), callee.string()};
fold::index::ProjectIndex index((directory.path / "project.sqlite").string());
REQUIRE(index.isOpen());
fold::ir::ProjectCallGraph cold_graph;
const auto cold = fold::cli::assembleProjectCallGraph(*database, files, &index, cold_graph);
REQUIRE_FALSE(cold.exhaustive_fallback);
CHECK(cold.parsed_tus == 2);
checkMatchesExhaustive(*database, files, cold_graph);
fold::ir::ProjectCallGraph warm_graph;
const auto warm = fold::cli::assembleProjectCallGraph(*database, files, &index, warm_graph);
REQUIRE_FALSE(warm.exhaustive_fallback);
CHECK(warm.parsed_tus == 0);
CHECK(warm.reused_tus == 2);
CHECK(countNamed(warm_graph, "overloaded") == 2);
CHECK(countNamePrefix(warm_graph, "header_local@") == 2);
checkMatchesExhaustive(*database, files, warm_graph);
}
TEST_CASE("reachability-only assembly reuses unresolved contributions without proving closure",
"[project-graph][incremental][production-reachability]") {
TempDir directory;
const auto source = directory.path / "indirect.cpp";
writeFile(source, "using Callback=void(*)(); static void target(){} "
"static void invoke(Callback callback){ callback(); } "
"int main(){ invoke(target); return 0; }\n");
writeCompileDb(directory.path, {source});
auto database = loadDb(directory.path);
const std::vector<std::string> files = {source.string()};
fold::index::ProjectIndex index((directory.path / "index.sqlite").string());
REQUIRE(index.isOpen());
fold::ir::ProjectCallGraph graph;
fold::cli::ProjectGraphEvidence cold_evidence;
const auto cold = fold::cli::assembleProjectCallGraph(*database, files, &index, graph, nullptr,
&cold_evidence, true);
CHECK_FALSE(cold.exhaustive_fallback);
CHECK(cold.parsed_tus == 1);
CHECK(cold_evidence.complete);
CHECK_FALSE(cold_evidence.roots.closed_world);
CHECK(cold_evidence.roots.has_unresolved_dynamic_calls);
fold::cli::ProjectGraphEvidence warm_evidence;
const auto warm = fold::cli::assembleProjectCallGraph(*database, files, &index, graph, nullptr,
&warm_evidence, true);
CHECK_FALSE(warm.exhaustive_fallback);
CHECK(warm.parsed_tus == 0);
CHECK(warm.reused_tus == 1);
CHECK(warm_evidence.complete);
CHECK_FALSE(warm_evidence.roots.closed_world);
}
TEST_CASE("production callback roots survive cross-TU declaration and definition merging",
"[project-graph][incremental][production-roots]") {
TempDir directory;
const auto callback = directory.path / "callback.cpp";
const auto entry = directory.path / "entry.cpp";
writeFile(callback, "void callback() {}\n");
writeFile(entry, "void register_callback(void (*)()); void callback(); "
"int main(){ register_callback(callback); return 0; }\n");
writeCompileDb(directory.path, {callback, entry});
auto database = loadDb(directory.path);
const std::vector<std::string> files = {callback.string(), entry.string()};
fold::index::ProjectIndex index((directory.path / "index.sqlite").string());
REQUIRE(index.isOpen());
fold::ir::ProjectCallGraph graph;
const auto cold = fold::cli::assembleProjectCallGraph(*database, files, &index, graph);
REQUIRE_FALSE(cold.exhaustive_fallback);
CHECK(cold.production_roots == 2);
CHECK(cold.root_inventory_closed_world);
checkMatchesExhaustive(*database, files, graph);
const auto loaded = index.loadTranslationUnitContributions();
REQUIRE(loaded.status == fold::index::ContributionLoadStatus::Ok);
std::vector<fold::analysis::ProductionRootRecord> roots;
for (const auto& contribution : loaded.contributions)
roots.insert(roots.end(), contribution.production_roots.begin(),
contribution.production_roots.end());
const auto callback_root = std::ranges::find_if(roots, [](const auto& root) {
return root.qualified_name == "callback" &&
root.kind == fold::analysis::ProductionRootKind::RegisteredCallback;
});
REQUIRE(callback_root != roots.end());
const auto* callback_node = graph.getNode(callback_root->function_usr);
REQUIRE(callback_node != nullptr);
CHECK(callback_node->is_defined);
CHECK(callback_node->escapes);
const auto warm = fold::cli::assembleProjectCallGraph(*database, files, &index, graph);
CHECK(warm.parsed_tus == 0);
CHECK(warm.reused_tus == 2);
CHECK(warm.production_roots == 2);
}
TEST_CASE("configuration profiles persist projected callsite guards and invalidate reuse",
"[project-graph][incremental][configuration]") {
TempDir directory;
const auto source = directory.path / "guarded.cpp";
writeFile(source, "bool feature; int runtime; void enabled(){} void disabled(){} "
"void entry(){ if (feature) enabled(); else disabled(); "
"if (feature + runtime) enabled(); if (runtime) disabled(); }\n");
writeCompileDb(directory.path, {source});
auto database = loadDb(directory.path);
const std::vector<std::string> files = {source.string()};
fold::index::ProjectIndex index((directory.path / "index.sqlite").string());
REQUIRE(index.isOpen());
const auto disabled = fold::config::parseConfigurationProfileText(
R"({"version":1,"entrypoints":["entry"],"assumptions":[{"kind":"global","selector":"feature","value":false}]})");
REQUIRE(disabled);
fold::ir::ProjectCallGraph graph;
fold::cli::ProjectGraphEvidence cold_evidence;
const auto cold = fold::cli::assembleProjectCallGraph(*database, files, &index, graph,
&*disabled.profile, &cold_evidence);
REQUIRE_FALSE(cold.exhaustive_fallback);
CHECK(cold.parsed_tus == 1);
CHECK(cold.guarded_callsites == 4);
CHECK(cold.projected_guards == 2);
CHECK(cold.unconditional_guards == 1);
CHECK(cold.unknown_guards == 1);
CHECK(cold.production_roots == 1);
CHECK(cold.test_roots == 0);
CHECK(cold.conditional_roots == 0);
CHECK(cold.unresolved_explicit_roots == 0);
CHECK(cold.root_inventory_closed_world);
CHECK(cold_evidence.complete);
CHECK(cold_evidence.roots.closed_world);
CHECK(cold_evidence.guarded_calls.size() == 4);
checkMatchesExhaustive(*database, files, graph);
const auto loaded = index.loadTranslationUnitContributions();
REQUIRE(loaded.status == fold::index::ContributionLoadStatus::Ok);
REQUIRE(loaded.contributions.size() == 1);
REQUIRE(loaded.contributions.front().guarded_calls.size() == 4);
REQUIRE(loaded.contributions.front().production_roots.size() == 1);
CHECK(loaded.contributions.front().production_roots.front().kind ==
fold::analysis::ProductionRootKind::Explicit);
std::size_t projected = 0;
std::size_t unconditional = 0;
std::size_t unknown = 0;
for (const auto& callsite : loaded.contributions.front().guarded_calls) {
const auto serialized = fold::ir::configGuardToJson(callsite.guard).dump();
CHECK(serialized.find("feature") == std::string::npos);
projected += callsite.guard.status == fold::ir::ConfigGuardStatus::Projected;
unconditional += callsite.guard.status == fold::ir::ConfigGuardStatus::Unconditional;
unknown += callsite.guard.status == fold::ir::ConfigGuardStatus::Unknown;
}
CHECK(projected == 2);
CHECK(unconditional == 1);
CHECK(unknown == 1);
fold::cli::ProjectGraphEvidence warm_evidence;
const auto warm = fold::cli::assembleProjectCallGraph(*database, files, &index, graph,
&*disabled.profile, &warm_evidence);
CHECK(warm.parsed_tus == 0);
CHECK(warm.reused_tus == 1);
CHECK(warm_evidence.complete);
CHECK(warm_evidence.guarded_calls == cold_evidence.guarded_calls);
CHECK(warm_evidence.roots.production_roots == cold_evidence.roots.production_roots);
const auto enabled = fold::config::parseConfigurationProfileText(
R"({"version":1,"entrypoints":["entry"],"assumptions":[{"kind":"global","selector":"feature","value":true}]})");
REQUIRE(enabled);
const auto changed =
fold::cli::assembleProjectCallGraph(*database, files, &index, graph, &*enabled.profile);
CHECK(changed.parsed_tus == 1);
CHECK(changed.reused_tus == 0);
checkMatchesExhaustive(*database, files, graph);
}
TEST_CASE("incremental project graph matches exhaustive after edits dependencies flags and removal",
"[project-graph][incremental][invalidate]") {
TempDir directory;
const auto header = directory.path / "value.hpp";
const auto caller = directory.path / "caller.cpp";
const auto callee = directory.path / "callee.cpp";
writeFile(header, "#define HEADER_VALUE 1\n");
writeFile(caller, "#include \"value.hpp\"\nint callee(int); "
"int caller(){ return callee(HEADER_VALUE); }\n");
writeFile(callee, "#include \"value.hpp\"\nint callee(int x){ return x + HEADER_VALUE; }\n");
writeCompileDb(directory.path, {caller, callee}, "-include" + header.string());
auto database = loadDb(directory.path);
std::vector<std::string> files{caller.string(), callee.string()};
fold::index::ProjectIndex index((directory.path / "project.sqlite").string());
fold::ir::ProjectCallGraph graph;
REQUIRE(fold::cli::assembleProjectCallGraph(*database, files, &index, graph).parsed_tus == 2);
writeFile(callee,
"#include \"value.hpp\"\nint callee(int x){ return x + HEADER_VALUE + 1; }\n");
auto edited = fold::cli::assembleProjectCallGraph(*database, files, &index, graph);
CHECK(edited.parsed_tus == 1);
CHECK(edited.dirty_tus == 1);
checkMatchesExhaustive(*database, files, graph);
writeFile(header, "#define HEADER_VALUE 2\n");
auto header_changed = fold::cli::assembleProjectCallGraph(*database, files, &index, graph);
CHECK(header_changed.parsed_tus == 2);
CHECK(header_changed.dirty_tus == 2);
checkMatchesExhaustive(*database, files, graph);
writeCompileDb(directory.path, {caller, callee}, "-DGRAPH_FLAG=1");
database = loadDb(directory.path);
auto flags_changed = fold::cli::assembleProjectCallGraph(*database, files, &index, graph);
CHECK(flags_changed.parsed_tus == 2);
CHECK(flags_changed.dirty_tus == 2);
checkMatchesExhaustive(*database, files, graph);
files = {caller.string()};
auto removed = fold::cli::assembleProjectCallGraph(*database, files, &index, graph);
CHECK(removed.parsed_tus == 0);
CHECK(removed.removed_tus == 1);
checkMatchesExhaustive(*database, files, graph);
const auto loaded = index.loadTranslationUnitContributions();
REQUIRE(loaded.status == fold::index::ContributionLoadStatus::Ok);
REQUIRE(loaded.contributions.size() == 1);
CHECK(loaded.contributions.front().fingerprint.source_path ==
std::filesystem::weakly_canonical(caller).string());
}
TEST_CASE("compile-command working directory invalidates TU contribution reuse",
"[project-graph][incremental][invalidate]") {
TempDir directory;
const auto first_working_directory = directory.path / "first";
const auto second_working_directory = directory.path / "second";
std::filesystem::create_directories(first_working_directory);
std::filesystem::create_directories(second_working_directory);
const auto source = directory.path / "entry.cpp";
writeFile(source, "int entry(){ return 1; }\n");
const std::vector<std::filesystem::path> source_paths{source};
const std::vector<std::string> files{source.string()};
writeCompileDb(directory.path, source_paths, {}, first_working_directory);
auto database = loadDb(directory.path);
fold::index::ProjectIndex index((directory.path / "project.sqlite").string());
fold::ir::ProjectCallGraph graph;
REQUIRE(fold::cli::assembleProjectCallGraph(*database, files, &index, graph).parsed_tus == 1);
writeCompileDb(directory.path, source_paths, {}, second_working_directory);
database = loadDb(directory.path);
const auto changed = fold::cli::assembleProjectCallGraph(*database, files, &index, graph);
CHECK(changed.dirty_tus == 1);
CHECK(changed.parsed_tus == 1);
}
TEST_CASE("unresolved virtual function-pointer evidence resolves in contribution merge",
"[project-graph][incremental][resolution]") {
TempDir directory;
const auto source = directory.path / "dynamic.cpp";
writeFile(source, "struct Base { virtual int run(); };\n"
"struct Derived : Base { int run() override { return 1; } };\n"
"int virtual_call(Base* value){ return value->run(); }\n"
"int indirect(int (*function)(double)){ return function(1.0); }\n");
writeCompileDb(directory.path, {source});
auto database = loadDb(directory.path);
const std::vector<std::string> files{source.string()};
fold::index::ProjectIndex index((directory.path / "project.sqlite").string());
fold::ir::ProjectCallGraph cold_graph;
const auto cold = fold::cli::assembleProjectCallGraph(*database, files, &index, cold_graph);
REQUIRE_FALSE(cold.exhaustive_fallback);
CHECK(cold.parsed_tus == 1);
CHECK(cold_graph.edgeCount(fold::ir::CallGraphEdgeKind::Conservative) >= 1);
CHECK(cold_graph.edgeCount(fold::ir::CallGraphEdgeKind::Unknown) >= 1);
checkMatchesExhaustive(*database, files, cold_graph);
// The warm run reuses the stored contribution: unresolved calls are
// resolved from the persisted cross-TU resolution facts instead of
// re-parsing every TU through the exhaustive fallback.
fold::ir::ProjectCallGraph warm_graph;
const auto warm = fold::cli::assembleProjectCallGraph(*database, files, &index, warm_graph);
REQUIRE_FALSE(warm.exhaustive_fallback);
CHECK(warm.parsed_tus == 0);
CHECK(warm.reused_tus == 1);
CHECK(warm_graph.edgeCount(fold::ir::CallGraphEdgeKind::Conservative) >= 1);
CHECK(warm_graph.edgeCount(fold::ir::CallGraphEdgeKind::Unknown) >= 1);
checkMatchesExhaustive(*database, files, warm_graph);
}