fold/tests/integration/test_subprocess_fanout.cpp
trvon fcf474e4f0
Some checks failed
build / build-linux (pull_request) Failing after 7m32s
build / build-macos (pull_request) Has been cancelled
build / release (pull_request) Has been cancelled
refactor(cli): one TU path normalizer for coverage, fan-out and quarantine
Problem: normalizeClangTuPath, normalizeQuarantinePath and fan-out's
normalizePath were three copies of the same absolute + weakly_canonical
routine. Coverage, worker records and the quarantine file must agree on a
TU's spelling, and three copies can drift apart.

Fix: move the routine to fold/cli/tu_path.hpp as normalizeTuPath and use it
at every site. No behaviour change.

Test: existing test_scan_coverage (path spellings), test_tu_quarantine and
test_subprocess_fanout, updated to the new name. Full suite: 140/140.
2026-09-24 21:46:12 -06:00

376 lines
14 KiB
C++

#include <clang/Tooling/CompilationDatabase.h>
#include <algorithm>
#include <catch2/catch_test_macros.hpp>
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <map>
#include <nlohmann/json.hpp>
#include <string>
#include <tuple>
#include <vector>
#include "fold/cli/scan_options.hpp"
#include "fold/cli/scan_runner.hpp"
#include "fold/cli/subprocess_fanout.hpp"
#include "fold/cli/tu_quarantine.hpp"
#include "fold/frontend/comp_db.hpp"
#include "fold/report/finding.hpp"
#include "fold/report/triage.hpp"
namespace {
class ScopedWorkerBinary {
public:
explicit ScopedWorkerBinary(const std::string& path) {
if (const char* current = std::getenv("FOLD_WORKER_BINARY"))
previous_ = current;
::setenv("FOLD_WORKER_BINARY", path.c_str(), 1);
}
~ScopedWorkerBinary() {
if (previous_.empty())
::unsetenv("FOLD_WORKER_BINARY");
else
::setenv("FOLD_WORKER_BINARY", previous_.c_str(), 1);
}
private:
std::string previous_;
};
std::string locateFixtures() {
const char* env = std::getenv("FOLD_FIXTURES_DIR");
std::string fixtures = env ? env : "../fixtures";
if (!std::filesystem::exists(fixtures + "/clones/exact.cpp")) {
fixtures = std::filesystem::current_path().string() + "/../fixtures";
}
REQUIRE(std::filesystem::exists(fixtures + "/clones/exact.cpp"));
return fixtures;
}
void writeCompileDb(const std::string& fixtures) {
namespace fs = std::filesystem;
nlohmann::json db = nlohmann::json::array();
for (const auto& f : {"clones/exact.cpp", "clones/renamed.cpp", "clones/near_miss.cpp"}) {
fs::path file = fs::path(fixtures) / f;
db.push_back({
{"directory", fixtures},
{"command", std::string("clang++ -std=c++20 -c ") + file.string()},
{"file", file.string()},
});
}
std::ofstream(fs::path(fixtures) / "compile_commands.json") << db.dump();
}
std::filesystem::path writeQualityCompileDb(const std::string& fixtures) {
namespace fs = std::filesystem;
const fs::path output = fs::temp_directory_path() / "fold-subprocess-quality";
fs::create_directories(output);
nlohmann::json db = nlohmann::json::array();
for (const auto& f : {"quality/structural_debt.cpp", "quality/structural_clean.cpp"}) {
fs::path file = fs::path(fixtures) / f;
db.push_back({
{"directory", fixtures},
{"command", std::string("clang++ -std=c++20 -c ") + file.string()},
{"file", file.string()},
});
}
std::ofstream(output / "compile_commands.json") << db.dump();
return output;
}
std::string locateFoldBinary() {
if (const char* env = std::getenv("FOLD_WORKER_BINARY")) {
if (*env && std::filesystem::exists(env)) {
return std::filesystem::canonical(env).string();
}
}
auto cwd = std::filesystem::current_path();
for (const auto& rel : {"../../src/cli/fold", "../../../src/cli/fold", "../src/cli/fold"}) {
auto candidate = cwd / rel;
if (std::filesystem::exists(candidate)) {
return std::filesystem::canonical(candidate).string();
}
}
return "";
}
} // namespace
TEST_CASE("SubprocessFanout matches serial output on fixtures", "[fanout][e2e]") {
auto fixtures = locateFixtures();
writeCompileDb(fixtures);
std::string err;
auto db = fold::loadCompilationDatabase(fixtures, err);
REQUIRE(db);
auto files = db->getAllFiles();
REQUIRE(files.size() == 3);
fold::cli::ScanOptions opts;
opts.compile_db = fixtures;
opts.detectors = {"clone"};
opts.min_body_stmts = 1;
// Serial reference.
auto serial = fold::cli::runDetectorsOnShard(*db, opts, files,
/*include_clone_clustering=*/true);
// Parallel via subprocess fan-out.
std::string fold_bin = locateFoldBinary();
if (fold_bin.empty()) {
WARN("could not locate fold binary; skipping fan-out comparison");
return;
}
::setenv("FOLD_WORKER_BINARY", fold_bin.c_str(), 1);
fold::cli::SubprocessFanout fanout;
auto parallel = fanout.runSync(opts, files, /*n_workers=*/2);
REQUIRE(parallel.size() == serial.findings.size());
// Sort both and compare ids — clone finding IDs encode the cluster key
// so identical inputs must yield identical IDs.
auto idsOf = [](auto&& v) {
std::vector<std::string> ids;
ids.reserve(v.size());
for (auto& f : v)
ids.push_back(f.id);
std::sort(ids.begin(), ids.end());
return ids;
};
REQUIRE(idsOf(parallel) == idsOf(serial.findings));
}
TEST_CASE("SubprocessFanout single worker == serial path", "[fanout][unit]") {
auto fixtures = locateFixtures();
writeCompileDb(fixtures);
std::string err;
auto db = fold::loadCompilationDatabase(fixtures, err);
REQUIRE(db);
auto files = db->getAllFiles();
fold::cli::ScanOptions opts;
opts.compile_db = fixtures;
opts.detectors = {"clone"};
opts.min_body_stmts = 1;
auto serial = fold::cli::runDetectorsOnShard(*db, opts, files,
/*include_clone_clustering=*/true);
std::string fold_bin = locateFoldBinary();
if (fold_bin.empty()) {
WARN("could not locate fold binary; skipping fan-out comparison");
return;
}
::setenv("FOLD_WORKER_BINARY", fold_bin.c_str(), 1);
fold::cli::SubprocessFanout fanout;
auto parallel = fanout.runSync(opts, files, /*n_workers=*/1);
REQUIRE(parallel.size() == serial.findings.size());
}
TEST_CASE("quarantined TUs are coverage gaps in serial and fan-out scans",
"[fanout][quarantine][coverage]") {
// A TU skipped because a prior run quarantined it was not analysed. The
// serial path dropped it from the attempted set (a complete scan with it
// missing); the fan-out path reported it as frontend-failed.
auto fixtures = locateFixtures();
writeCompileDb(fixtures);
std::string err;
auto db = fold::loadCompilationDatabase(fixtures, err);
REQUIRE(db);
auto files = db->getAllFiles();
REQUIRE(files.size() == 3);
const auto unique = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
const auto quarantine =
std::filesystem::temp_directory_path() / ("fold-quarantine-coverage-" + unique);
fold::cli::appendQuarantine(quarantine.string(), files[0], SIGSEGV);
fold::cli::ScanOptions opts;
opts.compile_db = fixtures;
opts.detectors = {"clone"};
opts.min_body_stmts = 1;
opts.quarantine_dir = quarantine.string();
auto quarantinedGap = [&](const fold::ScanCoverage& coverage) {
CHECK(coverage.tus_attempted == 3);
CHECK(coverage.tus_analyzed == 2);
REQUIRE(coverage.gaps.size() == 1);
CHECK(fold::cli::normalizeTuPath(coverage.gaps[0].file) ==
fold::cli::normalizeTuPath(files[0]));
CHECK(coverage.gaps[0].reason == "quarantined");
};
SECTION("serial") {
auto serial = fold::cli::runDetectorsOnShard(*db, opts, files,
/*include_clone_clustering=*/true);
quarantinedGap(serial.coverage);
}
SECTION("fan-out") {
std::string fold_bin = locateFoldBinary();
if (fold_bin.empty()) {
WARN("could not locate fold binary; skipping fan-out coverage");
return;
}
ScopedWorkerBinary worker(fold_bin);
fold::cli::SubprocessFanout fanout;
(void)fanout.runSync(opts, files, /*n_workers=*/2);
quarantinedGap(fanout.coverage());
}
std::error_code error;
std::filesystem::remove_all(quarantine, error);
}
TEST_CASE("SubprocessFanout isolates sequential frontend failures and preserves findings",
"[fanout][recovery]") {
const char* helper = std::getenv("FOLD_TEST_FANOUT_FAILURE_WORKER");
REQUIRE(helper != nullptr);
REQUIRE(std::filesystem::exists(helper));
ScopedWorkerBinary worker(helper);
const auto unique = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
const auto root = std::filesystem::temp_directory_path() / ("fold-fanout-recovery-" + unique);
const auto quarantine = root / "quarantine";
std::filesystem::create_directories(root);
const std::vector<std::string> files = {
(root / "before.cpp").string(),
(root / "missing_header.cpp").string(),
(root / "crash.cpp").string(),
(root / "after.cpp").string(),
};
fold::cli::ScanOptions opts;
opts.compile_db = (root / "compile_commands.json").string();
opts.quarantine_dir = quarantine.string();
opts.detectors = {"complexity"};
fold::cli::SubprocessFanout fanout;
const auto findings = fanout.runSync(opts, files, 1);
REQUIRE(findings.size() == 2);
std::vector<std::string> finding_ids;
for (const auto& finding : findings)
finding_ids.push_back(finding.id);
std::sort(finding_ids.begin(), finding_ids.end());
REQUIRE(finding_ids ==
std::vector<std::string>{"fanout-test:after.cpp", "fanout-test:before.cpp"});
const auto quarantined = fold::cli::loadQuarantine(quarantine.string());
REQUIRE(quarantined.contains(fold::cli::normalizeTuPath(files[1])));
REQUIRE(quarantined.contains(fold::cli::normalizeTuPath(files[2])));
std::ifstream input(quarantine / "quarantine.txt");
const std::string records((std::istreambuf_iterator<char>(input)),
std::istreambuf_iterator<char>());
REQUIRE(records.find("missing_header.cpp\tworker-exit:3") != std::string::npos);
REQUIRE(records.find("crash.cpp\tsignal:" + std::to_string(SIGSEGV)) != std::string::npos);
std::error_code error;
std::filesystem::remove_all(root, error);
}
TEST_CASE("SubprocessFanout kills a stalled worker and preserves surrounding TU findings",
"[fanout][recovery][timeout]") {
const char* helper = std::getenv("FOLD_TEST_FANOUT_FAILURE_WORKER");
REQUIRE(helper != nullptr);
REQUIRE(std::filesystem::exists(helper));
ScopedWorkerBinary worker(helper);
const auto unique = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count());
const auto root = std::filesystem::temp_directory_path() / ("fold-fanout-timeout-" + unique);
const auto quarantine = root / "quarantine";
std::filesystem::create_directories(root);
const std::vector<std::string> files = {
(root / "before.cpp").string(),
(root / "hang.cpp").string(),
(root / "after.cpp").string(),
};
fold::cli::ScanOptions opts;
opts.compile_db = (root / "compile_commands.json").string();
opts.quarantine_dir = quarantine.string();
opts.detectors = {"complexity"};
opts.per_tu_timeout_seconds = 1;
fold::cli::SubprocessFanout fanout;
const auto started = std::chrono::steady_clock::now();
const auto findings = fanout.runSync(opts, files, 1);
const auto elapsed = std::chrono::steady_clock::now() - started;
REQUIRE(elapsed < std::chrono::seconds(5));
REQUIRE(findings.size() == 2);
std::vector<std::string> finding_ids;
for (const auto& finding : findings)
finding_ids.push_back(finding.id);
std::sort(finding_ids.begin(), finding_ids.end());
REQUIRE(finding_ids ==
std::vector<std::string>{"fanout-test:after.cpp", "fanout-test:before.cpp"});
std::ifstream input(quarantine / "quarantine.txt");
const std::string records((std::istreambuf_iterator<char>(input)),
std::istreambuf_iterator<char>());
REQUIRE(records.find("hang.cpp\ttimeout:1") != std::string::npos);
std::error_code error;
std::filesystem::remove_all(root, error);
}
TEST_CASE("SubprocessFanout propagates worker startup failures", "[fanout][recovery]") {
REQUIRE(std::filesystem::exists("/usr/bin/false"));
ScopedWorkerBinary worker("/usr/bin/false");
fold::cli::ScanOptions opts;
opts.compile_db = "/missing/compile-db";
opts.detectors = {"complexity"};
fold::cli::SubprocessFanout fanout;
REQUIRE_THROWS(fanout.runSync(opts, {"input.cpp"}, 1));
}
TEST_CASE("SubprocessFanout preserves structural quality settings and evidence",
"[fanout][quality]") {
auto fixtures = locateFixtures();
auto compile_db_dir = writeQualityCompileDb(fixtures);
std::string err;
auto db = fold::loadCompilationDatabase(compile_db_dir.string(), err);
REQUIRE(db);
auto files = db->getAllFiles();
REQUIRE(files.size() == 2);
fold::cli::ScanOptions opts;
opts.compile_db = compile_db_dir.string();
opts.detectors = {"clone", "equiv", "class-dup",
"complexity", "dead-statement", "unused-function"};
opts.min_body_stmts = 2;
opts.complexity_threshold = 5;
auto serial = fold::cli::runDetectorsOnShard(*db, opts, files,
/*include_clone_clustering=*/true);
fold::triageFindings(serial.findings, opts);
std::string fold_bin = locateFoldBinary();
if (fold_bin.empty()) {
WARN("could not locate fold binary; skipping quality fan-out comparison");
return;
}
::setenv("FOLD_WORKER_BINARY", fold_bin.c_str(), 1);
fold::cli::SubprocessFanout fanout;
auto parallel = fanout.runSync(opts, files, /*n_workers=*/2);
using FindingView = std::tuple<std::string, double, bool>;
auto views = [](const std::vector<fold::Finding>& findings) {
std::map<std::string, FindingView> out;
for (const auto& finding : findings) {
out.emplace(finding.id, FindingView{finding.message, finding.score,
finding.evidence.has_cross_tu_corroboration});
}
return out;
};
REQUIRE(views(parallel) == views(serial.findings));
REQUIRE(std::any_of(parallel.begin(), parallel.end(), [](const fold::Finding& finding) {
return finding.kind == fold::FindingKind::HighComplexity;
}));
}