fold/tests/unit/test_scan_coverage.cpp
trvon c9811b7d41
fix(scan): report unanalyzed translation units and exit 2
Problem: a TU the front end could not parse left no trace. ClangTool::run's
status was discarded, TUs with compile errors were analyzed on a partial
AST and counted as done, and the isolated-worker path dropped TUs that
never produced an AST. A scan of only broken files printed no findings and
exited 0, the same as a clean scan. Separately, the scan logger wrote to
stdout, so any warning corrupted a SARIF/JSON report written to stdout.

Fix: every TU now yields an outcome (clean, compile-errors,
analysis-failed); workers carry it on their file_finished record, and the
fan-out parent adds timeout/crash/worker-exit for the TU a worker died on.
tuCoverage() turns the requested file list plus outcomes into a
ScanCoverage (requested TUs with no outcome are frontend-failed). SARIF
gets a run `invocations` entry (executionSuccessful, one
toolExecutionNotification per gap); findings JSON gets an additive
`coverage` object. A scan with any gap, or with nothing analyzed, exits 2
after writing its report. CLI diagnostics now go to stderr, as
docs/internal/cli-philosophy.md principle 12 requires. USAGE.md and
AGENTS.md document the coverage fields and the exit code.

Test: tests/unit/test_scan_coverage.cpp (classification, path spelling,
complete/untracked controls, merge, both writers, and unchanged output
without coverage); test_cli_paths "[coverage]" covers a clean TU (exit 0,
complete) and a TU with a missing include (exit 2, gap in JSON, SARIF on
stdout parseable with executionSuccessful=false). Before: both probe files
exited 0 with no coverage information. Full suite: 139/139.
2026-09-24 20:37:51 -06:00

112 lines
4.6 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <filesystem>
#include <nlohmann/json.hpp>
#include <sstream>
#include <string>
#include <vector>
#include "fold/cli/clang_shard_tu.hpp"
#include "fold/report/findings_json_report.hpp"
#include "fold/report/sarif.hpp"
#include "fold/report/scan_coverage.hpp"
// A TU the front end could not parse used to vanish: the report had no trace
// of it and the scan exited 0, indistinguishable from a clean scan. Coverage
// now records every requested TU that was not analyzed on a clean AST.
namespace {
std::string tmp(const std::string& name) {
return (std::filesystem::temp_directory_path() / name).string();
}
} // namespace
TEST_CASE("tuCoverage classifies every requested TU", "[coverage]") {
const std::vector<std::string> requested{tmp("ok.cpp"), tmp("broken.cpp"), tmp("missing.cpp"),
tmp("slow.cpp")};
const std::vector<fold::cli::ClangShardTuOutcome> outcomes{
{tmp("ok.cpp"), ""},
{tmp("broken.cpp"), "compile-errors"},
{tmp("slow.cpp"), "timeout"},
};
const auto coverage = fold::cli::tuCoverage(requested, outcomes);
CHECK(coverage.tracked);
CHECK(coverage.tus_attempted == 4);
CHECK(coverage.tus_analyzed == 1);
REQUIRE(coverage.gaps.size() == 3);
CHECK(coverage.gaps[0].file == tmp("broken.cpp"));
CHECK(coverage.gaps[0].reason == "compile-errors");
CHECK(coverage.gaps[1].file == tmp("missing.cpp"));
CHECK(coverage.gaps[1].reason == "frontend-failed");
CHECK(coverage.gaps[2].reason == "timeout");
CHECK(coverage.incomplete());
}
TEST_CASE("tuCoverage matches paths across spellings", "[coverage]") {
// The compile DB may spell a path differently than the front end does.
const auto dir = std::filesystem::temp_directory_path();
const std::string dotted = (dir / "sub" / ".." / "a.cpp").string();
const auto coverage = fold::cli::tuCoverage({dotted}, {{(dir / "a.cpp").string(), ""}});
CHECK(coverage.tus_analyzed == 1);
CHECK_FALSE(coverage.incomplete());
}
TEST_CASE("coverage: complete, untracked and empty scans", "[coverage]") {
SECTION("every TU analyzed is complete (control)") {
const auto coverage = fold::cli::tuCoverage({tmp("a.cpp")}, {{tmp("a.cpp"), ""}});
CHECK_FALSE(coverage.incomplete());
}
SECTION("an untracked lane is never incomplete") {
fold::ScanCoverage coverage;
coverage.tus_attempted = 3;
CHECK_FALSE(coverage.incomplete());
}
SECTION("attempted TUs with none analyzed is incomplete even without gaps") {
fold::ScanCoverage coverage;
coverage.tracked = true;
coverage.tus_attempted = 2;
CHECK(coverage.incomplete());
}
SECTION("merge sums counts and keeps gaps; an untracked side is ignored") {
auto a = fold::cli::tuCoverage({tmp("a.cpp")}, {{tmp("a.cpp"), ""}});
const auto b = fold::cli::tuCoverage({tmp("b.cpp")}, {});
a.merge(b);
a.merge(fold::ScanCoverage{});
CHECK(a.tus_attempted == 2);
CHECK(a.tus_analyzed == 1);
CHECK(a.gaps.size() == 1);
}
}
TEST_CASE("reports carry coverage", "[coverage][report]") {
const auto coverage =
fold::cli::tuCoverage({tmp("broken.cpp")}, {{tmp("broken.cpp"), "compile-errors"}});
SECTION("SARIF: invocation is unsuccessful and names the TU") {
std::ostringstream out;
fold::writeFilteredSarif(out, {}, &coverage);
const auto sarif = nlohmann::json::parse(out.str());
const auto& invocation = sarif["runs"][0]["invocations"][0];
CHECK(invocation["executionSuccessful"] == false);
const auto& note = invocation["toolExecutionNotifications"][0];
CHECK(note["level"] == "error");
CHECK(note["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] ==
tmp("broken.cpp"));
CHECK(invocation["properties"]["coverage"]["tus_analyzed"] == 0);
}
SECTION("findings JSON: coverage object") {
std::ostringstream out;
fold::writeFindingsJson(out, {}, &coverage);
const auto report = nlohmann::json::parse(out.str());
CHECK(report["coverage"]["complete"] == false);
CHECK(report["coverage"]["gaps"][0]["reason"] == "compile-errors");
}
SECTION("no coverage argument leaves the documents unchanged") {
std::ostringstream sarif_out;
fold::writeFilteredSarif(sarif_out, {});
CHECK_FALSE(nlohmann::json::parse(sarif_out.str())["runs"][0].contains("invocations"));
std::ostringstream json_out;
fold::writeFindingsJson(json_out, {});
CHECK_FALSE(nlohmann::json::parse(json_out.str()).contains("coverage"));
}
}