From 74bc1d1f17c60300cdff8aa27c2897c8df1b6fdc Mon Sep 17 00:00:00 2001 From: Chris Graf Date: Tue, 30 Jun 2026 13:06:18 -0500 Subject: [PATCH] Require compile context for `clang-tidy` Summary Require generic C/C++ compile context before Checkrun plans or runs `clang-tidy`. `.clang-tidy` configures rule selection, but it does not provide the compiler arguments needed for a meaningful standalone `clang-tidy` invocation. Treating it as an enablement signal made Checkrun run raw `clang-tidy` in repos that provide their C++ compiler context through another toolchain. - use `compile_commands.json` and `compile_flags.txt` as the `clang-tidy` registry gate - keep `.clang-tidy` as an optional `--config-file` when compile context exists - update fast lint and verify-time adapters to share the same rule - cover `.clang-tidy`-only, compile-database, compile-flags, and combined config cases Testing - `./bin/checkrun format README.md lib/checkrun/verify.py lib/checkrun/linters/languages.sh share/checkrun/registry.json test/suites/autolint-test test/suites/registry-test test/suites/verify-test` - `./bin/checkrun lint README.md lib/checkrun/verify.py lib/checkrun/linters/languages.sh share/checkrun/registry.json test/suites/autolint-test test/suites/registry-test test/suites/verify-test` - `test/suites/registry-test` - `test/suites/verify-test` - `test/suites/autolint-test` - `test/checkrun-test` --- README.md | 12 ++++---- lib/checkrun/linters/languages.sh | 6 ++-- lib/checkrun/verify.py | 2 +- share/checkrun/registry.json | 6 +--- test/suites/autolint-test | 20 +++++++++++-- test/suites/registry-test | 48 +++++++++++++++++++++++++++++-- test/suites/verify-test | 20 +++++++++---- 7 files changed, 91 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 7559277..eb51260 100644 --- a/README.md +++ b/README.md @@ -333,9 +333,11 @@ dynamically scoped `fix` and `json` behavior. | Systemd units | - | `systemd-analyze verify` | `clang-tidy` is intentionally gated: it runs only when the target file's parent -chain contains `.clang-tidy`, `compile_commands.json`, or `compile_flags.txt`. -That keeps save-time editor lint quiet for standalone C/C++ files while still -using project-owned rule and compile metadata when it exists. +chain contains `compile_commands.json` or `compile_flags.txt`. A nearby +`.clang-tidy` configures rules once that compile context exists, but does not +enable `clang-tidy` by itself. That keeps save-time editor lint quiet for +standalone C/C++ files while still using project-owned rule and compile metadata +when it exists. Broad project analyzers are intentionally not listed as file linters. Run them explicitly with `checkrun verify [PATH...]` or `--tool` filters: @@ -351,8 +353,8 @@ explicitly with `checkrun verify [PATH...]` or `--tool` filters: intentional: dependency-audit results belong to locked project state, not standalone Rust source files. - Verify-time `clang-tidy` walks selected C/C++ files and directories while - preserving the same `.clang-tidy`, `compile_commands.json`, or - `compile_flags.txt` metadata gate as fast lint. + preserving the same `compile_commands.json` or `compile_flags.txt` metadata + gate as fast lint. - Deleted or renamed file paths are still useful verify scope hints: Go/Rust paths select their nearest surviving module or project, while deleted C/C++ files select the nearest surviving non-root directory context. diff --git a/lib/checkrun/linters/languages.sh b/lib/checkrun/linters/languages.sh index d10884d..67b38b8 100644 --- a/lib/checkrun/linters/languages.sh +++ b/lib/checkrun/linters/languages.sh @@ -141,7 +141,9 @@ _lint_clang_tidy() { # The registry-level config probe is the single source of truth for whether # clang-tidy may run at all. Once it is allowed, discover rule config and # compile metadata independently because projects commonly have both and - # clang-tidy needs them on different CLI flags. + # clang-tidy needs them on different CLI flags. A .clang-tidy file configures + # checks, but a compile database or compile flags file is what makes the + # per-file invocation meaningful. clang_config=$(_find_config "$dir" .clang-tidy 2>/dev/null || true) compile_db=$(_find_config "$dir" compile_commands.json 2>/dev/null || true) compile_flags=$(_find_config "$dir" compile_flags.txt 2>/dev/null || true) @@ -153,7 +155,7 @@ _lint_clang_tidy() { compile_root="" fi - [ -n "$clang_config$compile_root" ] || return 0 + [ -n "$compile_root" ] || return 0 [ -n "$clang_config" ] && args+=("--config-file=$clang_config") [ -n "$compile_root" ] && args+=("-p=$compile_root") diff --git a/lib/checkrun/verify.py b/lib/checkrun/verify.py index b3a8d6f..b71ad4d 100755 --- a/lib/checkrun/verify.py +++ b/lib/checkrun/verify.py @@ -543,7 +543,7 @@ def _clang_tidy_args(file: Path) -> list[str] | None: clang_config = _nearest_project_root(file, (".clang-tidy",)) compile_db = _nearest_project_root(file, ("compile_commands.json",)) compile_flags = _nearest_project_root(file, ("compile_flags.txt",)) - if not clang_config and not compile_db and not compile_flags: + if not compile_db and not compile_flags: return None args = ["--quiet"] diff --git a/share/checkrun/registry.json b/share/checkrun/registry.json index fe8ac31..db18eaf 100644 --- a/share/checkrun/registry.json +++ b/share/checkrun/registry.json @@ -149,11 +149,7 @@ "fallback": { "file": "clang-format" } }, "clang-tidy": { - "project": [ - { "file": ".clang-tidy" }, - { "file": "compile_commands.json" }, - { "file": "compile_flags.txt" } - ], + "project": [{ "file": "compile_commands.json" }, { "file": "compile_flags.txt" }], "native": "none" }, "cmake-format": { diff --git a/test/suites/autolint-test b/test/suites/autolint-test index d38a161..79a95ab 100755 --- a/test/suites/autolint-test +++ b/test/suites/autolint-test @@ -803,19 +803,34 @@ _assert_exit "cpp: clang-tidy without metadata exits 0" 0 "$rc" _assert_not_contains "cpp: clang-tidy without metadata is skipped" \ "MOCK-CLANG-TIDY-ARGS:" "$output" +_clang_tidy_only=$(_tmpdir) +mkdir -p "$_clang_tidy_only/src" +: >"$_clang_tidy_only/.clang-tidy" +echo 'int main() { return 0; }' >"$_clang_tidy_only/src/main.cpp" +set +e +output=$(PATH="$_clang_tidy_mockbin:$PATH" "$AUTOLINT" "$_clang_tidy_only/src/main.cpp" 2>&1) +rc=$? +set -e +_assert_exit "cpp: clang-tidy with .clang-tidy only exits 0" 0 "$rc" +_assert_not_contains "cpp: clang-tidy with .clang-tidy only is skipped" \ + "MOCK-CLANG-TIDY-ARGS:" "$output" + _clang_with_config=$(_tmpdir) mkdir -p "$_clang_with_config/src" : >"$_clang_with_config/.clang-tidy" +: >"$_clang_with_config/compile_commands.json" echo 'int main() { return 0; }' >"$_clang_with_config/src/main.cpp" set +e output=$(PATH="$_clang_tidy_mockbin:$PATH" "$AUTOLINT" "$_clang_with_config/src/main.cpp" 2>&1) rc=$? set -e -_assert_exit "cpp: clang-tidy with .clang-tidy exits 0" 0 "$rc" -_assert_contains "cpp: clang-tidy with .clang-tidy runs" \ +_assert_exit "cpp: clang-tidy with config and compile context exits 0" 0 "$rc" +_assert_contains "cpp: clang-tidy with config and compile context runs" \ "MOCK-CLANG-TIDY-ARGS:" "$output" _assert_contains_path_arg "cpp: clang-tidy receives --config-file" \ "--config-file=" "$_clang_with_config/.clang-tidy" "$output" +_assert_contains_path_arg "cpp: clang-tidy receives -p compile context root" \ + "-p=" "$_clang_with_config" "$output" _clang_with_db=$(_tmpdir) mkdir -p "$_clang_with_db/src" @@ -2747,6 +2762,7 @@ EOF chmod +x "$_json_clang_mock/clang-tidy" _json_clang_dir=$(_tmpdir) : >"$_json_clang_dir/.clang-tidy" +: >"$_json_clang_dir/compile_commands.json" cat >"$_json_clang_dir/main.cpp" <<'EOF' int main() { return 0; diff --git a/test/suites/registry-test b/test/suites/registry-test index b4c1665..619acbd 100755 --- a/test/suites/registry-test +++ b/test/suites/registry-test @@ -203,12 +203,11 @@ for relative, (expected_format, expected_lint) in matrix.items(): assert [step["adapter"] for step in format_steps] == expected_format, relative assert [step["adapter"] for step in lint_steps] == expected_lint, relative -clang_config_payloads = { - ".clang-tidy": "Checks: '*'\n", +clang_context_payloads = { "compile_commands.json": "[]\n", "compile_flags.txt": "-std=c++20\n", } -for config_name, config_payload in clang_config_payloads.items(): +for config_name, config_payload in clang_context_payloads.items(): c_with_config = ( tmp / f"with-clang-config-{config_name.replace('.', '_')}" / "src" / "main.cpp" ) @@ -230,6 +229,49 @@ for config_name, config_payload in clang_config_payloads.items(): assert clang_step["config"]["source"] == "project" assert clang_step["config"]["path"].endswith(config_name) +c_with_tidy_only = tmp / "with-clang-tidy-only" / "src" / "main.cpp" +c_with_tidy_only.parent.mkdir(parents=True, exist_ok=True) +(c_with_tidy_only.parent.parent / ".clang-tidy").write_text( + "Checks: '*'\n", encoding="utf-8" +) +c_with_tidy_only.write_text("", encoding="utf-8") +c_with_tidy_only_plan = registry_mod.plan(registry, [str(c_with_tidy_only)], "lint")[ + "files" +][0]["lint"] +assert [step["adapter"] for step in c_with_tidy_only_plan["steps"]] == [ + "typos", + "schema-lint", +] +clang_tidy_only_skips = [ + step for step in c_with_tidy_only_plan["skipped"] if step["adapter"] == "clang-tidy" +] +assert len(clang_tidy_only_skips) == 1 +assert clang_tidy_only_skips[0]["requiresConfigMatch"] is True +assert clang_tidy_only_skips[0]["reason"] == "missing-config" +assert clang_tidy_only_skips[0]["config"]["source"] == "none" + +c_with_config_and_context = tmp / "with-clang-config-and-context" / "src" / "main.cpp" +c_with_config_and_context.parent.mkdir(parents=True, exist_ok=True) +(c_with_config_and_context.parent.parent / ".clang-tidy").write_text( + "Checks: '*'\n", encoding="utf-8" +) +(c_with_config_and_context.parent.parent / "compile_commands.json").write_text( + "[]\n", encoding="utf-8" +) +c_with_config_and_context.write_text("", encoding="utf-8") +c_with_config_and_context_plan = registry_mod.plan( + registry, [str(c_with_config_and_context)], "lint" +)["files"][0]["lint"] +assert [step["adapter"] for step in c_with_config_and_context_plan["steps"]] == [ + "typos", + "schema-lint", + "clang-tidy", +] +clang_step = c_with_config_and_context_plan["steps"][-1] +assert clang_step["requiresConfigMatch"] is True +assert clang_step["config"]["source"] == "project" +assert clang_step["config"]["path"].endswith("compile_commands.json") + c_without_config = tmp / "without-clang-config" / "main.cpp" c_without_config.parent.mkdir(parents=True, exist_ok=True) c_without_config.write_text("", encoding="utf-8") diff --git a/test/suites/verify-test b/test/suites/verify-test index f994802..195f2f9 100755 --- a/test/suites/verify-test +++ b/test/suites/verify-test @@ -106,6 +106,7 @@ EOF cat >"$_default_root/cpp/.clang-tidy" <<'EOF' Checks: '-*,bugprone-*' EOF +: >"$_default_root/cpp/compile_commands.json" cat >"$_default_root/cpp/src/main.cpp" <<'EOF' int main() { return 0; } EOF @@ -432,15 +433,21 @@ _assert_eq "cargo-clippy: deleted non-rust path is not a project scope hint" "" echo "=== checkrun: clang-tidy file discovery ===" _cpp_root=$(_realpath_dir "$(_tmpdir)") +_cpp_tidy_only_root=$(_realpath_dir "$(_tmpdir)") _cpp_unconfigured_root=$(_realpath_dir "$(_tmpdir)") -mkdir -p "$_cpp_root/src" "$_cpp_root/include" "$_cpp_unconfigured_root" +mkdir -p "$_cpp_root/src" "$_cpp_root/include" "$_cpp_tidy_only_root" "$_cpp_unconfigured_root" : >"$_cpp_root/.clang-tidy" +: >"$_cpp_root/compile_commands.json" cat >"$_cpp_root/src/main.cpp" <<'EOF' int main() { return 0; } EOF cat >"$_cpp_root/include/demo.hpp" <<'EOF' int demo(); EOF +cat >"$_cpp_tidy_only_root/tidy-only.cpp" <<'EOF' +int tidy_only(); +EOF +: >"$_cpp_tidy_only_root/.clang-tidy" cat >"$_cpp_unconfigured_root/no-config.cpp" <<'EOF' int no_config(); EOF @@ -461,6 +468,7 @@ CLANG_TIDY_LOG="$_clang_tidy_log" PATH="$_clang_tidy_mock:$PATH" \ "$_cpp_root/src/main.cpp" \ "$_cpp_root/src/main.cpp" \ "$_cpp_root/include" \ + "$_cpp_tidy_only_root/tidy-only.cpp" \ "$_cpp_unconfigured_root/no-config.cpp" >/dev/null 2>&1 rc=$? set -e @@ -468,9 +476,11 @@ _assert_exit "clang-tidy: exits 0 for clean files" 0 "$rc" _clang_tidy_call_count=$(wc -l <"$_clang_tidy_log" | tr -d ' ') _assert_eq "clang-tidy: deduplicates repeated files" "2" "$_clang_tidy_call_count" _assert_contains "clang-tidy: runs selected source file with project config" \ - "--quiet --config-file=$_cpp_root/.clang-tidy $_cpp_root/src/main.cpp" "$(cat "$_clang_tidy_log")" + "--quiet --config-file=$_cpp_root/.clang-tidy -p=$_cpp_root $_cpp_root/src/main.cpp" "$(cat "$_clang_tidy_log")" _assert_contains "clang-tidy: walks selected directories for C++ headers" \ - "--quiet --config-file=$_cpp_root/.clang-tidy $_cpp_root/include/demo.hpp" "$(cat "$_clang_tidy_log")" + "--quiet --config-file=$_cpp_root/.clang-tidy -p=$_cpp_root $_cpp_root/include/demo.hpp" "$(cat "$_clang_tidy_log")" +_assert_not_contains "clang-tidy: .clang-tidy without compile context is skipped" \ + "$_cpp_tidy_only_root/tidy-only.cpp" "$(cat "$_clang_tidy_log")" _assert_not_contains "clang-tidy: skips C++ files without metadata gate" \ "$_cpp_unconfigured_root/no-config.cpp" "$(cat "$_clang_tidy_log")" @@ -482,7 +492,7 @@ rc=$? set -e _assert_exit "clang-tidy: deleted file scope exits 0" 0 "$rc" _assert_contains "clang-tidy: deleted file scopes to surviving directory context" \ - "--quiet --config-file=$_cpp_root/.clang-tidy $_cpp_root/src/main.cpp" "$(cat "$_clang_tidy_log")" + "--quiet --config-file=$_cpp_root/.clang-tidy -p=$_cpp_root $_cpp_root/src/main.cpp" "$(cat "$_clang_tidy_log")" _assert_not_contains "clang-tidy: deleted file does not lint missing path" \ "$_cpp_root/src/deleted.cpp" "$(cat "$_clang_tidy_log")" @@ -494,7 +504,7 @@ rc=$? set -e _assert_exit "clang-tidy: deleted parent scope exits 0" 0 "$rc" _assert_contains "clang-tidy: deleted parent scopes to surviving directory context" \ - "--quiet --config-file=$_cpp_root/.clang-tidy $_cpp_root/src/main.cpp" "$(cat "$_clang_tidy_log")" + "--quiet --config-file=$_cpp_root/.clang-tidy -p=$_cpp_root $_cpp_root/src/main.cpp" "$(cat "$_clang_tidy_log")" _assert_not_contains "clang-tidy: deleted parent does not lint missing path" \ "$_cpp_root/src/removed/deleted.cpp" "$(cat "$_clang_tidy_log")"