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.
376 lines
14 KiB
C++
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;
|
|
}));
|
|
}
|