// 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 #include #include #include #include #include #include #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> findingMembers(const std::vector& findings) { std::map> 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& 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 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{"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 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 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 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 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{"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 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{"comparison-cap"}); } TEST_CASE("clone summary origin metadata survives worker serialization") { auto summary = makeSummary("a", "render", "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{"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{"visitor-callback-name", "exact-hash-family"}); } TEST_CASE("clone messages bound large family member lists") { std::vector 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", "include/render.hpp", 10, 101, 201, 301); auto second = makeSummary("b", "render", "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{"template-origin", "exact-hash-family"}); } TEST_CASE("type-3 rename clone above threshold is emitted with its similarity") { std::vector 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 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 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 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 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 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()); }