fold/tests/unit/test_clone.cpp
trvon 86b2f14a43
feat(analysis): harden project-wide scans
- persist TU-owned graphs and guarded configuration evidence
- add closed-world production reachability classification
- calibrate detector precision and reviewed self-scan policy
- terminate poisoned workers instead of longjmp recovery
2026-07-30 18:18:04 -04:00

403 lines
17 KiB
C++

// Unit tests for the hash-bucket clone detector (src/detect/clone.cpp).
// Exercises the public detectClones entry point with synthetic summaries:
// exact/rename/literal bucketing, claimed-id exclusion, Type-3 similarity
// thresholds, clone_min_score gating, header re-include dedup, and the
// unscored-oversize-bucket regression (a bucket whose bodies all exceed the
// token length cap must not fabricate a perfect score).
#include <clang/Tooling/Tooling.h>
#include <algorithm>
#include <catch2/catch_test_macros.hpp>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include "fold/detect/clone.hpp"
#include "fold/frontend/ast_visitor.hpp"
#include "fold/report/finding_json.hpp"
namespace {
fold::FunctionSummary makeSummary(std::string id, std::string name, std::string file, int line,
std::uint64_t h_exact, std::uint64_t h_rename,
std::uint64_t h_literal, std::string body_rename = {},
std::string body_literal = {}, int stmts = 8) {
fold::FunctionSummary s;
s.id = id;
s.qualified_name = name;
s.file = file;
s.line = line;
s.column = 1;
s.body_stmt_count = stmts;
s.hash_exact = h_exact;
s.hash_rename_insensitive = h_rename;
s.hash_literal_insensitive = h_literal;
s.body_rename = body_rename;
s.body_literal = body_literal;
return s;
}
std::map<std::string, std::vector<std::string>>
findingMembers(const std::vector<fold::Finding>& findings) {
std::map<std::string, std::vector<std::string>> members;
for (const auto& finding : findings) {
auto& names = members[finding.id];
for (const auto& location : finding.locations)
names.push_back(location.function);
}
return members;
}
std::string summaryId(const std::string& code, const std::string& function) {
auto unit = clang::tooling::buildASTFromCodeWithArgs(code, {"-std=c++20"}, "input.cpp");
REQUIRE(unit);
const auto summaries = fold::collectFunctionSummariesInContext(unit->getASTContext());
const auto summary =
std::ranges::find(summaries, function, &fold::FunctionSummary::qualified_name);
REQUIRE(summary != summaries.end());
return summary->id;
}
bool hasFindingWith(const std::vector<fold::Finding>& findings, const std::string& needle) {
for (const auto& f : findings) {
if (f.id.find(needle) != std::string::npos)
return true;
}
return false;
}
} // namespace
TEST_CASE("exact bucket emits one finding scored 1.0 and claims its members") {
std::vector<fold::FunctionSummary> summaries;
// Same hashes under all three normalizations: the looser modes would
// cluster these too, but Exact must claim them first, producing exactly 1 finding.
summaries.push_back(makeSummary("a", "f_a", "a.cpp", 10, 111, 222, 333, "aa", "aa"));
summaries.push_back(makeSummary("b", "f_b", "a.cpp", 20, 111, 222, 333, "aa", "aa"));
fold::CloneDetectorOptions opts; // defaults: min_stmts=4, type=2, min_score=0
auto findings = fold::detectClones(summaries, opts);
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].kind == fold::FindingKind::Clone);
REQUIRE(findings[0].clone_score.has_value());
REQUIRE(*findings[0].clone_score == 1.0);
REQUIRE(findings[0].message.find("exact") != std::string::npos);
REQUIRE(findings[0].locations.size() == 2);
REQUIRE(findings[0].evidence.quality.has_value());
REQUIRE(findings[0].evidence.quality->member_ids == std::vector<std::string>{"a", "b"});
REQUIRE(findings[0].evidence.quality->representative_id == "a");
}
TEST_CASE("function summary identity survives harmless preceding insertion", "[clone][identity]") {
const std::string baseline = R"(
int helper(int value) { return value + 1; }
)";
const std::string transformed = R"(
int unrelated() { return 0; }
int helper(int value) { return value + 1; }
)";
REQUIRE(summaryId(baseline, "helper") == summaryId(transformed, "helper"));
}
TEST_CASE("clone family identity survives formatting-only changes", "[clone][identity]") {
const std::string baseline = R"(
int alpha(int value) { int x = value + 1; x += 2; x *= 3; return x; }
int beta(int value) { int x = value + 1; x += 2; x *= 3; return x; }
)";
const std::string formatted = R"(
int alpha(int value) {
int x = value + 1;
x += 2;
x *= 3;
return x;
}
int beta(int value) {
int x = value + 1;
x += 2;
x *= 3;
return x;
}
)";
const auto findings_for = [](const std::string& code) {
auto unit = clang::tooling::buildASTFromCodeWithArgs(code, {"-std=c++20"}, "input.cpp");
REQUIRE(unit);
const auto summaries = fold::collectFunctionSummariesInContext(unit->getASTContext());
return fold::detectClones(summaries);
};
const auto before = findings_for(baseline);
const auto after = findings_for(formatted);
REQUIRE(before.size() == 1);
REQUIRE(after.size() == 1);
REQUIRE(before.front().id == after.front().id);
}
TEST_CASE("overloaded function summaries have distinct stable identities", "[clone][identity]") {
auto unit = clang::tooling::buildASTFromCodeWithArgs(
"int helper(int value) { return value; } double helper(double value) { return value; }",
{"-std=c++20"}, "input.cpp");
REQUIRE(unit);
const auto summaries = fold::collectFunctionSummariesInContext(unit->getASTContext());
REQUIRE(summaries.size() == 2);
REQUIRE(summaries[0].id != summaries[1].id);
}
TEST_CASE("clone members and identities are independent of summary order", "[clone][identity]") {
std::vector<fold::FunctionSummary> summaries{
makeSummary("a", "alpha", "a.cpp", 10, 111, 211, 311, "aa", "aa"),
makeSummary("b", "beta", "b.cpp", 20, 111, 211, 311, "aa", "aa"),
makeSummary("c", "gamma", "c.cpp", 30, 112, 212, 312, "cc", "cc"),
makeSummary("d", "delta", "d.cpp", 40, 112, 212, 312, "cc", "cc"),
};
const auto forward = findingMembers(fold::detectClones(summaries));
std::ranges::reverse(summaries);
const auto reversed = findingMembers(fold::detectClones(summaries));
REQUIRE(forward == reversed);
}
TEST_CASE("header re-includes are deduped by (file, line, column, hash_exact)") {
std::vector<fold::FunctionSummary> summaries;
// Same function appearing once per including TU: identical location tuple
// and exact hash. Without dedup this self-clusters as a false clone.
summaries.push_back(makeSummary("tu1", "serialize", "hdr.h", 42, 999, 888, 777));
summaries.push_back(makeSummary("tu2", "serialize", "hdr.h", 42, 999, 888, 777));
auto findings = fold::detectClones(summaries, {});
REQUIRE(findings.empty());
}
TEST_CASE("functions below min_body_stmts are skipped") {
std::vector<fold::FunctionSummary> summaries;
summaries.push_back(makeSummary("a", "tiny_a", "a.cpp", 10, 111, 222, 333, "aa", "aa", 2));
summaries.push_back(makeSummary("b", "tiny_b", "a.cpp", 20, 111, 222, 333, "aa", "aa", 2));
fold::CloneDetectorOptions opts; // min_body_stmts = 4
auto findings = fold::detectClones(summaries, opts);
REQUIRE(findings.empty());
}
TEST_CASE("type-3 clone detection emits one transitive family") {
std::vector<fold::FunctionSummary> summaries;
summaries.push_back(makeSummary("a", "alpha", "a.cpp", 10, 101, 201, 301, "aaaaaaaaaa", "xx"));
summaries.push_back(makeSummary("b", "beta", "a.cpp", 20, 102, 201, 302, "aaaaaaaabb", "yy"));
summaries.push_back(makeSummary("c", "gamma", "a.cpp", 30, 103, 201, 303, "aaaaaabbbb", "zz"));
summaries.push_back(
makeSummary("d", "delta", "a.cpp", 40, 104, 201, 304, std::string(100, 'a'), "ww"));
fold::CloneDetectorOptions opts;
opts.clone_type = 3;
opts.type3_similarity = 0.75;
fold::CloneDetectionStats stats;
const auto findings = fold::detectClones(summaries, opts, &stats);
REQUIRE(findings.size() == 1);
REQUIRE(stats.candidate_pairs == 6);
REQUIRE(stats.length_filtered_pairs == 3);
REQUIRE(stats.edit_distance_comparisons == 3);
REQUIRE(stats.capped_buckets == 0);
REQUIRE(findings.front().locations.size() == 3);
REQUIRE(findings.front().evidence.quality.has_value());
REQUIRE(findings.front().evidence.quality->member_ids ==
std::vector<std::string>{"a", "b", "c"});
REQUIRE(findings.front().evidence.quality->metrics.front().name == "family_size");
REQUIRE(findings.front().evidence.quality->metrics.front().value == 3.0);
const auto length_filtered =
std::ranges::find(findings.front().evidence.quality->metrics, "length_filtered_pairs",
&fold::QualityMetric::name);
REQUIRE(length_filtered != findings.front().evidence.quality->metrics.end());
REQUIRE(length_filtered->value == 3.0);
}
TEST_CASE("type-3 family reports bounded comparison incompleteness") {
std::vector<fold::FunctionSummary> summaries;
summaries.push_back(makeSummary("a", "alpha", "a.cpp", 10, 101, 201, 301, "aaaaaaaaaa", "xx"));
summaries.push_back(makeSummary("b", "beta", "a.cpp", 20, 102, 201, 302, "aaaaaaaabb", "yy"));
summaries.push_back(makeSummary("c", "gamma", "a.cpp", 30, 103, 201, 303, "aaaaaabbbb", "zz"));
fold::CloneDetectorOptions opts;
opts.clone_type = 3;
opts.type3_similarity = 0.75;
opts.max_pairs_per_bucket = 1;
const auto findings = fold::detectClones(summaries, opts);
REQUIRE(findings.size() == 1);
REQUIRE(findings.front().evidence.quality->incomplete);
REQUIRE(findings.front().evidence.quality->uncertainty_reasons ==
std::vector<std::string>{"comparison-cap"});
}
TEST_CASE("clone summary origin metadata survives worker serialization") {
auto summary = makeSummary("a", "render<int>", "include/render.hpp", 10, 101, 201, 301);
summary.is_template = true;
summary.from_macro = true;
const auto restored = fold::summaryFromJson(fold::summaryToJson(summary));
REQUIRE(restored.is_template);
REQUIRE(restored.from_macro);
}
TEST_CASE("same-name overload clone families retain signatures and rank as boilerplate") {
auto first = makeSummary("usr-int", "fold::Env::lookup", "src/env.cpp", 10, 101, 201, 301);
auto second = makeSummary("usr-string", "fold::Env::lookup", "src/env.cpp", 20, 101, 202, 302);
first.signature = "int (int) const";
second.signature = "int (const char*) const";
const auto findings = fold::detectClones({first, second}, {});
REQUIRE(findings.size() == 1);
REQUIRE(findings.front().evidence.quality->classification == "overload-boilerplate");
REQUIRE(findings.front().evidence.quality->rank_reasons ==
std::vector<std::string>{"same-qualified-name-overloads", "exact-hash-family"});
CHECK(findings.front().message.find("int (int) const") != std::string::npos);
CHECK(findings.front().message.find("int (const char*) const") != std::string::npos);
}
TEST_CASE("CRTP callback clone families rank as callback boilerplate") {
auto first =
makeSummary("visit-a", "fold::FirstVisitor::VisitCallExpr", "src/a.cpp", 10, 101, 201, 301);
auto second = makeSummary("visit-b", "fold::SecondVisitor::VisitCallExpr", "src/b.cpp", 20, 101,
202, 302);
const auto findings = fold::detectClones({first, second}, {});
REQUIRE(findings.size() == 1);
REQUIRE(findings.front().evidence.quality->classification == "callback-boilerplate");
REQUIRE(findings.front().evidence.quality->rank_reasons ==
std::vector<std::string>{"visitor-callback-name", "exact-hash-family"});
}
TEST_CASE("clone messages bound large family member lists") {
std::vector<fold::FunctionSummary> summaries;
for (int index = 0; index < 12; ++index)
summaries.push_back(
makeSummary("id-" + std::to_string(index),
"fold::Visitor" + std::to_string(index) + "::VisitFunctionDecl",
"src/v.cpp", 10 + index, 101, 201, 301));
const auto findings = fold::detectClones(summaries, {});
REQUIRE(findings.size() == 1);
CHECK(findings.front().message.size() < 512);
CHECK(findings.front().message.find("more") != std::string::npos);
}
TEST_CASE("clone families classify template-origin review units") {
auto first = makeSummary("a", "render<int>", "include/render.hpp", 10, 101, 201, 301);
auto second = makeSummary("b", "render<long>", "include/render.hpp", 20, 101, 202, 302);
first.is_template = true;
second.is_template = true;
const auto findings = fold::detectClones({first, second}, {});
REQUIRE(findings.size() == 1);
REQUIRE(findings.front().evidence.quality->classification == "template");
REQUIRE(findings.front().evidence.quality->rank_reasons ==
std::vector<std::string>{"template-origin", "exact-hash-family"});
}
TEST_CASE("type-3 rename clone above threshold is emitted with its similarity") {
std::vector<fold::FunctionSummary> summaries;
// Distinct exact hashes (no Type-2 exact bucket), same rename bucket.
// Bodies differ in 2 of 10 chars, so similarity 0.8 >= 0.7.
summaries.push_back(makeSummary("a", "f_a", "a.cpp", 10, 101, 201, 301, "aaaaaaaaaa", "xx"));
summaries.push_back(makeSummary("b", "f_b", "a.cpp", 20, 102, 201, 302, "aaaaaaaabb", "yy"));
fold::CloneDetectorOptions opts;
opts.clone_type = 3;
opts.type3_similarity = 0.7;
auto findings = fold::detectClones(summaries, opts);
REQUIRE(findings.size() == 1);
REQUIRE(hasFindingWith(findings, "type3-rename"));
REQUIRE(findings[0].clone_score.has_value());
REQUIRE(*findings[0].clone_score > 0.79);
REQUIRE(*findings[0].clone_score < 0.81);
}
TEST_CASE("type-3 pair below threshold emits nothing") {
std::vector<fold::FunctionSummary> summaries;
// Same rename bucket but completely different bodies, so similarity 0.0.
summaries.push_back(makeSummary("a", "f_a", "a.cpp", 10, 101, 201, 301, "aaaaaaaaaa", "xx"));
summaries.push_back(makeSummary("b", "f_b", "a.cpp", 20, 102, 201, 302, "bbbbbbbbbb", "yy"));
fold::CloneDetectorOptions opts;
opts.clone_type = 3;
opts.type3_similarity = 0.7;
auto findings = fold::detectClones(summaries, opts);
REQUIRE(findings.empty());
}
TEST_CASE("type-3 literal clone is found under the literal bucket") {
std::vector<fold::FunctionSummary> summaries;
// Distinct exact AND rename hashes; only the literal bucket clusters.
summaries.push_back(makeSummary("a", "f_a", "a.cpp", 10, 101, 201, 301, "xx", "aaaaaaaaaa"));
summaries.push_back(makeSummary("b", "f_b", "a.cpp", 20, 102, 202, 301, "yy", "aaaaaaaabb"));
fold::CloneDetectorOptions opts;
opts.clone_type = 3;
opts.type3_similarity = 0.7;
auto findings = fold::detectClones(summaries, opts);
REQUIRE(findings.size() == 1);
REQUIRE(hasFindingWith(findings, "type3-literal"));
}
TEST_CASE("oversize bodies leave the bucket unscored instead of fabricating 1.0") {
std::vector<fold::FunctionSummary> summaries;
// Both rename bodies exceed the 800-char scoring cap, leaving no scoreable pair.
// Regression: computeClusterScore used to return 1.0 here, so any
// clone_min_score gate passed silently.
summaries.push_back(
makeSummary("a", "big_a", "a.cpp", 10, 101, 201, 301, std::string(900, 'a'), "xx"));
summaries.push_back(
makeSummary("b", "big_b", "a.cpp", 20, 102, 201, 302, std::string(899, 'a') + 'b', "yy"));
fold::CloneDetectorOptions opts;
opts.clone_type = 2;
opts.max_body_len = 800;
opts.clone_min_score = 0.99; // would reject any honestly-scored imperfect pair
auto findings = fold::detectClones(summaries, opts);
// The bucket cannot be disproved, so it is still reported, but it must
// carry no score and say so in the message.
REQUIRE(findings.size() == 1);
REQUIRE(!findings[0].clone_score.has_value());
REQUIRE(findings[0].message.find("unscored") != std::string::npos);
}
TEST_CASE("lifted default body cap scores long Type-3 candidates") {
std::vector<fold::FunctionSummary> summaries;
summaries.push_back(
makeSummary("a", "long_a", "a.cpp", 10, 101, 201, 301, std::string(900, 'a'), "xx"));
summaries.push_back(
makeSummary("b", "long_b", "a.cpp", 20, 102, 201, 302, std::string(899, 'a') + 'b', "yy"));
fold::CloneDetectorOptions opts;
opts.clone_type = 3;
opts.type3_similarity = 0.99;
auto findings = fold::detectClones(summaries, opts);
REQUIRE(findings.size() == 1);
REQUIRE(findings[0].clone_score.has_value());
REQUIRE(*findings[0].clone_score > 0.998);
}
TEST_CASE("clone_min_score gates out a low-similarity type-2 cluster") {
std::vector<fold::FunctionSummary> summaries;
summaries.push_back(makeSummary("a", "f_a", "a.cpp", 10, 101, 201, 301, "aaaaaaaaaa", "xx"));
summaries.push_back(makeSummary("b", "f_b", "a.cpp", 20, 102, 201, 302, "bbbbbbbbbb", "yy"));
fold::CloneDetectorOptions opts;
opts.clone_type = 2;
opts.clone_min_score = 0.95; // cluster similarity is 0.0, so it is gated out
auto findings = fold::detectClones(summaries, opts);
REQUIRE(findings.empty());
}