- 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
403 lines
17 KiB
C++
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());
|
|
}
|