From 29f1bb27c4755f8593c53d1560d868d5a89c56e7 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 26 Sep 2026 19:49:08 +0200 Subject: [PATCH 1/2] AST checks: bare -include (GCC PCH) has its file argument dropped, causing a fatal parse error Fixes #120 --- CHANGELOG.md | 1 + src/devops/cpp/ast/rule.py | 52 +++++++++++++----- tests/cpp/ast/test_rule.py | 105 ++++++++++++++++++++++++++++++++++++- 3 files changed, 144 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e0f97f..fca25f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file. - AST checks now parse full function bodies (previously skipped for performance), so statement-level checks like `noThrowParen` can see inside them - Auto-detect and pass `-resource-dir` to libclang when parsing, using the project's own compiler from `compile_commands.json` (or `clang++`/`clang` on PATH as a fallback). Pip's `libclang` wheel ships no builtin headers, so without this, parsing real files could silently hit a fatal error partway through and stop analyzing the rest of the file - Surface a fatal libclang parse error (e.g. an unresolvable `#include`) as a visible `astParseError` diagnostic instead of silently reporting no issues for the unparsed remainder of the file +- Fix a bug in `compile_commands.json` arg filtering where a bare `-include ` (as GCC/CMake's `target_precompile_headers` emits, unlike Clang's `-Xclang -include -Xclang `) had its file argument silently dropped by the positional-source-file heuristic, leaving a dangling `-include` that swallowed the next unrelated flag as its filename and caused a fatal parse error. `-include` (bare or `-Xclang`-wrapped, including `-include-pch`) is now always dropped together with its file argument, since a force-included PCH header may not exist for every cmake target ## [0.3.0](https://github.com/repo/owner/releases/tag/0.3.0) - 2026-09-22 diff --git a/src/devops/cpp/ast/rule.py b/src/devops/cpp/ast/rule.py index 2e40760..d750df6 100644 --- a/src/devops/cpp/ast/rule.py +++ b/src/devops/cpp/ast/rule.py @@ -20,8 +20,12 @@ # Flags that are not useful to libclang and whose following argument (if any) -# should also be dropped. -_SKIP_WITH_ARG = frozenset(("-o", "-MF", "-MT", "-MQ")) +# should also be dropped. -include is dropped entirely (not just filtered by +# the source-extension check below) because a force-included PCH header may +# not exist for every cmake target and would otherwise cause a hard parse +# failure; see the -Xclang handling below for the Clang-specific spelling of +# the same flag. +_SKIP_WITH_ARG = frozenset(("-o", "-MF", "-MT", "-MQ", "-include")) # Flags that are not useful but take no following argument. _SKIP_ALONE = frozenset( ( @@ -36,6 +40,38 @@ _SOURCE_EXTENSIONS = (".cpp", ".cxx", ".cc", ".c", ".hpp", ".hxx", ".hh", ".h") +def _consume_xclang(it: typing.Iterator[str], result: list[str]) -> None: + """Handle the token(s) after an ``-Xclang`` already consumed from `it`. + + Drops ``-Xclang -include-pch`` / ``-Xclang -include`` and their file + argument entirely, same as the bare ``-include`` case in + `_args_from_compile_commands`: a force-included PCH header may not exist + for every cmake target and would otherwise cause a hard parse failure. + That file argument may itself be wrapped in another ``-Xclang`` + (``-Xclang -include-pch -Xclang ``) or bare + (``-Xclang -include-pch ``) depending on the CMake/Clang version, + so this peeks one token rather than assuming a fixed shape. Every other + ``-Xclang `` pair is appended to `result` as-is. + + Parameters + ---------- + it: typing.Iterator[str] + The shared argument iterator, positioned right after ``-Xclang``. + result: list[str] + The filtered-args list being built; appended to in place. + + """ + xclang_arg = next(it, None) + if xclang_arg in ("-include-pch", "-include"): + following = next(it, None) # the file, bare or another -Xclang + if following == "-Xclang": + next(it, None) # the file itself, wrapped + return + if xclang_arg is not None: + result.append("-Xclang") + result.append(xclang_arg) + + def _args_from_compile_commands( db: clang.CompilationDatabase, path: Path ) -> list[str] | None: @@ -59,18 +95,8 @@ def _args_from_compile_commands( # Skip source / header files passed as positional arguments. if not arg.startswith("-") and arg.endswith(_SOURCE_EXTENSIONS): continue - # Handle -Xclang pairs. Drop -include-pch entirely - # (the .gch file may not exist for every cmake target and causes a - # hard parse failure). All other -Xclang pairs are kept as-is. if arg == "-Xclang": - xclang_arg = next(it, None) - if xclang_arg == "-include-pch": - next(it, None) # skip following -Xclang - next(it, None) # skip the .gch file path - continue - if xclang_arg is not None: - result.append(arg) - result.append(xclang_arg) + _consume_xclang(it, result) continue result.append(arg) # Clang/libclang compatibility: silently ignore GCC-only flags that diff --git a/tests/cpp/ast/test_rule.py b/tests/cpp/ast/test_rule.py index c2bcfb6..887a7b0 100644 --- a/tests/cpp/ast/test_rule.py +++ b/tests/cpp/ast/test_rule.py @@ -2,11 +2,13 @@ from __future__ import annotations +import json import typing +import clang.cindex as clang import pytest -from devops.cpp.ast.rule import ASTChecksRule +from devops.cpp.ast.rule import ASTChecksRule, _args_from_compile_commands from devops.rules import FileRuleInput, ResultTypeEnum if typing.TYPE_CHECKING: @@ -66,3 +68,104 @@ def test_ok_when_path_missing(self) -> None: result = rule.apply(FileRuleInput(file_content="void foo() {}", path=None)) assert result.value == ResultTypeEnum.Ok + + +def _write_compile_commands( + tmp_path: Path, directory: Path, file: Path, command: str +) -> None: + """Write a one-entry compile_commands.json under `directory`.""" + (directory / "compile_commands.json").write_text( + json.dumps( + [{"directory": str(tmp_path), "file": str(file), "command": command}] + ) + ) + + +class TestArgsFromCompileCommands: + """`-include`/PCH handling: bare (GCC) and -Xclang-wrapped (Clang) forms. + + Regression coverage for a real bug: a bare `-include .hxx` (as GCC + emits for CMake's target_precompile_headers, unlike Clang's + `-Xclang -include -Xclang .hxx`) used to have its file argument + silently dropped by the positional-source-file heuristic (it ends in + `.hxx`, one of `_SOURCE_EXTENSIONS`) while the `-include` flag itself + was kept — leaving a dangling `-include` that then swallowed the next + *unrelated* flag in the args list as its filename, producing a fatal + "file not found" libclang parse error for a file that was never + referenced by the project at all. + """ + + def test_bare_include_and_its_file_are_both_dropped(self, tmp_path: Path) -> None: + """Test bare include and its file are both dropped.""" + db_dir = tmp_path + cpp_file = tmp_path / "foo.cpp" + cpp_file.write_text("void f() {}\n") + command = ( + "/usr/bin/g++-13 -Winvalid-pch -include " + f"{tmp_path}/cmake_pch.hxx -std=c++23 -o foo.o -c {cpp_file}" + ) + _write_compile_commands(tmp_path, db_dir, cpp_file, command) + + db = clang.CompilationDatabase.fromDirectory(str(db_dir)) + args = _args_from_compile_commands(db, cpp_file) + + assert args is not None + assert "-include" not in args + assert not any(a.endswith("cmake_pch.hxx") for a in args) + # No flag is left dangling right before our own appended flags. + idx = args.index("-Wno-unknown-warning-option") + assert args[idx - 1] != "-include" + + def test_xclang_wrapped_include_pch_is_dropped(self, tmp_path: Path) -> None: + """Test xclang wrapped include pch is dropped.""" + db_dir = tmp_path + cpp_file = tmp_path / "foo.cpp" + cpp_file.write_text("void f() {}\n") + command = ( + "/usr/bin/clang++-20 -Xclang -include-pch -Xclang " + f"{tmp_path}/pch.pch -std=c++23 -o foo.o -c {cpp_file}" + ) + _write_compile_commands(tmp_path, db_dir, cpp_file, command) + + db = clang.CompilationDatabase.fromDirectory(str(db_dir)) + args = _args_from_compile_commands(db, cpp_file) + + assert args is not None + assert "-include-pch" not in args + assert not any(a.endswith("pch.pch") for a in args) + + def test_xclang_wrapped_include_is_dropped(self, tmp_path: Path) -> None: + """Test xclang wrapped include is dropped (Clang's own -include form).""" + db_dir = tmp_path + cpp_file = tmp_path / "foo.cpp" + cpp_file.write_text("void f() {}\n") + command = ( + "/usr/bin/clang++-20 -Xclang -include -Xclang " + f"{tmp_path}/cmake_pch.hxx -std=c++23 -o foo.o -c {cpp_file}" + ) + _write_compile_commands(tmp_path, db_dir, cpp_file, command) + + db = clang.CompilationDatabase.fromDirectory(str(db_dir)) + args = _args_from_compile_commands(db, cpp_file) + + assert args is not None + assert "-include" not in args + assert not any(a.endswith("cmake_pch.hxx") for a in args) + + def test_other_xclang_pairs_are_preserved(self, tmp_path: Path) -> None: + """Test other xclang pairs are preserved.""" + db_dir = tmp_path + cpp_file = tmp_path / "foo.cpp" + cpp_file.write_text("void f() {}\n") + command = ( + "/usr/bin/clang++-20 -Xclang -fsome-frontend-flag " + f"-std=c++23 -o foo.o -c {cpp_file}" + ) + _write_compile_commands(tmp_path, db_dir, cpp_file, command) + + db = clang.CompilationDatabase.fromDirectory(str(db_dir)) + args = _args_from_compile_commands(db, cpp_file) + + assert args is not None + assert "-Xclang" in args + assert "-fsome-frontend-flag" in args From 9684fdab1c8113e56b82b21c20c166e2bd29cc15 Mon Sep 17 00:00:00 2001 From: Jakob Gamper <97gamjak@gmail.com> Date: Sat, 26 Sep 2026 19:55:28 +0200 Subject: [PATCH 2/2] AST checks: bare -include (GCC PCH) has its file argument dropped, causing a fatal parse error Fixes #120 --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1691ef3..3f01131 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. ## Next Release +### Fixes + +#### CPP Rules + +- Fix a bug in `compile_commands.json` arg filtering where a bare `-include ` (as GCC/CMake's `target_precompile_headers` emits, unlike Clang's `-Xclang -include -Xclang `) had its file argument silently dropped by the positional-source-file heuristic, leaving a dangling `-include` that swallowed the next unrelated flag as its filename and caused a fatal parse error. `-include` (bare or `-Xclang`-wrapped, including `-include-pch`) is now always dropped together with its file argument, since a force-included PCH header may not exist for every cmake target + ## [0.4.0](https://github.com/repo/owner/releases/tag/0.4.0) - 2026-09-26 @@ -20,7 +26,6 @@ All notable changes to this project will be documented in this file. - AST checks now parse full function bodies (previously skipped for performance), so statement-level checks like `noThrowParen` can see inside them - Auto-detect and pass `-resource-dir` to libclang when parsing, using the project's own compiler from `compile_commands.json` (or `clang++`/`clang` on PATH as a fallback). Pip's `libclang` wheel ships no builtin headers, so without this, parsing real files could silently hit a fatal error partway through and stop analyzing the rest of the file - Surface a fatal libclang parse error (e.g. an unresolvable `#include`) as a visible `astParseError` diagnostic instead of silently reporting no issues for the unparsed remainder of the file -- Fix a bug in `compile_commands.json` arg filtering where a bare `-include ` (as GCC/CMake's `target_precompile_headers` emits, unlike Clang's `-Xclang -include -Xclang `) had its file argument silently dropped by the positional-source-file heuristic, leaving a dangling `-include` that swallowed the next unrelated flag as its filename and caused a fatal parse error. `-include` (bare or `-Xclang`-wrapped, including `-include-pch`) is now always dropped together with its file argument, since a force-included PCH header may not exist for every cmake target ## [0.3.0](https://github.com/repo/owner/releases/tag/0.3.0) - 2026-09-22