diff --git a/implementations/Crafter.Build-Lint.cpp b/implementations/Crafter.Build-Lint.cpp index 53f77f8..7317011 100644 --- a/implementations/Crafter.Build-Lint.cpp +++ b/implementations/Crafter.Build-Lint.cpp @@ -953,6 +953,9 @@ format` applies it to disk, and `crafter-build lint` reports where it would. return PathInsideRoot(p, cwd) ? p.lexically_relative(cwd) : p; }; + std::map astFailures; + std::set skippedAstRules; + // Any rule reading Decls() needs this configuration's module PCMs, and a // parse without them is fatal rather than degraded. Produce them up front // rather than letting each file fail on its own, and cache the assembled @@ -1020,8 +1023,12 @@ format` applies it to disk, and `crafter-build lint` reports where it would. // from reporting it clean. if (ctx.compileCommand.empty()) continue; if (!ctx.AstAvailable()) { - ctx.Report(0, std::format("rule '{}' needs an AST, which is unavailable: {}", rule->name, ctx.AstUnavailableReason())); - ++summary.errors; + // Recorded once per file and reported as a single grouped + // error after the run. One missing PCM would otherwise + // produce a finding per (file, rule) and bury the one fact + // that matters — which is that a build has to happen first. + astFailures.emplace(file, ctx.AstUnavailableReason()); + skippedAstRules.insert(rule->name); continue; } } @@ -1113,6 +1120,18 @@ format` applies it to disk, and `crafter-build lint` reports where it would. } } + if (!astFailures.empty()) { + std::string rules; + for (std::string_view name : skippedAstRules) { + if (!rules.empty()) rules += ", "; + rules += name; + } + std::println(std::cerr, "lint: {} rule(s) needing an AST ({}) could not run on {} of {} file(s).", skippedAstRules.size(), rules, astFailures.size(), summary.filesLinted); + std::println(std::cerr, " {}: {}", shown(astFailures.begin()->first).string(), astFailures.begin()->second); + std::println(std::cerr, " Build the project first so the module PCMs exist, or pass --no-ast to skip these rules."); + ++summary.errors; + } + std::sort(summary.findings.begin(), summary.findings.end(), [](const LintFinding& a, const LintFinding& b) { return std::tie(a.file, a.line) < std::tie(b.file, b.line); diff --git a/tests/Lint/main.cpp b/tests/Lint/main.cpp index 82e61a0..404e141 100644 --- a/tests/Lint/main.cpp +++ b/tests/Lint/main.cpp @@ -699,7 +699,10 @@ int main() { Check(!ran, "ast: rule is skipped when the AST is unavailable"); Check(summary.errors > 0, "ast: unavailable AST counts as an error"); Check(!summary.Clean(), "ast: unavailable AST is not Clean"); - Check(std::any_of(summary.findings.begin(), summary.findings.end(), [](const LintFinding& f) { return f.message.contains("needs an AST"); }), "ast: a finding explains why"); + // Explained on stderr as one grouped message rather than a finding per + // (file, rule): a single missing PCM would otherwise bury the one fact + // that matters. The run still failing is the part that counts, and the + // two assertions above cover it. } // --no-ast skips those rules deliberately and exits normally.