From 7f71030b5bbfbb1da2ceff40f75abaed25bdc8e4 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Fri, 31 Jul 2026 00:05:40 +0200 Subject: [PATCH] fix(lint): report an unavailable AST once per run, not per (file, rule) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linting a march whose PCMs are not built produced 102 findings — 34 files times three AST rules — for a single fact: the project has not been built for that configuration. The one actionable sentence was buried. Now recorded per file and reported once, naming the rules that could not run, how many files were affected, one example reason, and what to do about it. Still one error, so the run still fails; --no-ast remains the way to proceed without building. Co-Authored-By: Claude Opus 5 (1M context) --- implementations/Crafter.Build-Lint.cpp | 23 +++++++++++++++++++++-- tests/Lint/main.cpp | 5 ++++- 2 files changed, 25 insertions(+), 3 deletions(-) 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.